Sanitize tag export filenames via django.utils.text.slugify
Some checks failed
Build Docker image / buildx (push) Has been cancelled
Run linters / lint (push) Has been cancelled
Build Pip package / build (push) Has been cancelled
Release State / release-state (push) Has been cancelled
Run tests / python_tests (ubuntu-22.04, 3.13) (push) Has been cancelled
Run tests / docker_tests (push) Has been cancelled

Addresses review feedback from cubic and devin: quote()'s percent-
encoding isn't decoded by browsers in Content-Disposition's filename
parameter (Safari saves literal %20). Switch to Django's slugify()
which does NFKD normalization, ASCII transliteration, and replaces
punctuation with hyphens — producing clean names like
"tag-alpha-research-urls.txt".

- Add tag_filename_safe(name) helper wrapping slugify
- Use it in both tag export endpoints
- Drop the now-unneeded JS fallback name (server always sets
  Content-Disposition)
This commit is contained in:
Claude 2026-04-21 17:30:50 +00:00
parent b83e2de73a
commit 0041a2d407
No known key found for this signature in database
4 changed files with 14 additions and 11 deletions

View File

@ -2,7 +2,6 @@ __package__ = "archivebox.api"
import math
from collections import defaultdict
from urllib.parse import quote
from uuid import UUID
from typing import Union, Any, Annotated
from datetime import datetime
@ -37,6 +36,7 @@ from archivebox.core.tag_utils import (
normalize_has_snapshots_filter,
normalize_tag_sort,
rename_tag as rename_tag_record,
tag_filename_safe,
)
from archivebox.crawls.models import Crawl
from archivebox.api.v1_crawls import CrawlSchema
@ -725,8 +725,7 @@ def tag_urls_export(request: HttpRequest, tag_id: int):
raise HttpError(404, "Tag not found") from err
response = HttpResponse(export_tag_urls(tag), content_type="text/plain; charset=utf-8")
# TODO: potentially harden this more, e.g. replace all special characters with ANSII equivalents / strip punctuation / etc.
response["Content-Disposition"] = f'attachment; filename="tag-{quote(tag.name, safe="")}-urls.txt"'
response["Content-Disposition"] = f'attachment; filename="tag-{tag_filename_safe(tag.name)}-urls.txt"'
return response
@ -738,8 +737,7 @@ def tag_snapshots_export(request: HttpRequest, tag_id: int):
raise HttpError(404, "Tag not found") from err
response = HttpResponse(export_tag_snapshots_jsonl(tag), content_type="application/x-ndjson; charset=utf-8")
# TODO: potentially harden this more, e.g. replace all special characters with ANSII equivalents / strip punctuation / etc.
response["Content-Disposition"] = f'attachment; filename="tag-{quote(tag.name, safe="")}-snapshots.jsonl"'
response["Content-Disposition"] = f'attachment; filename="tag-{tag_filename_safe(tag.name)}-snapshots.jsonl"'
return response

View File

@ -10,6 +10,7 @@ from django.db.models import Count, F, QuerySet
from django.db.models.functions import Lower
from django.http import HttpRequest
from django.urls import reverse
from django.utils.text import slugify
from archivebox.core.host_utils import build_snapshot_url, build_web_url
from archivebox.core.models import Snapshot, SnapshotTag, Tag
@ -35,6 +36,11 @@ def normalize_tag_name(name: str) -> str:
return (name or "").strip()
def tag_filename_safe(name: str) -> str:
"""ASCII-safe filename fragment for a tag name (via django.utils.text.slugify)."""
return slugify(name or "") or "tag"
def normalize_tag_sort(sort: str = "created_desc") -> str:
valid_sorts = {key for key, _label in TAG_SORT_CHOICES}
return sort if sort in valid_sorts else "created_desc"

View File

@ -974,8 +974,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (action === 'download-jsonl') {
actionButton.disabled = true;
try {
const tagName = cardEl.querySelector('.tag-card__display strong')?.textContent || 'tag';
await downloadFileFromUrl(cardEl.dataset.exportJsonlUrl, 'tag-' + encodeURIComponent(tagName) + '-snapshots.jsonl');
await downloadFileFromUrl(cardEl.dataset.exportJsonlUrl, 'tag-snapshots.jsonl');
} catch (error) {
setToast(error.message || 'Failed to download JSONL.', 'error');
} finally {

View File

@ -179,11 +179,11 @@ def test_tag_snapshots_export_returns_jsonl(client, api_token, tagged_data):
HTTP_HOST=ADMIN_HOST,
)
from urllib.parse import quote
from archivebox.core.tag_utils import tag_filename_safe
assert response.status_code == 200
assert response["Content-Type"].startswith("application/x-ndjson")
assert f"tag-{quote(tag.name, safe='')}-snapshots.jsonl" in response["Content-Disposition"]
assert f"tag-{tag_filename_safe(tag.name)}-snapshots.jsonl" in response["Content-Disposition"]
body = response.content.decode()
assert '"type": "Snapshot"' in body
assert '"tags": "Alpha Research"' in body
@ -198,10 +198,10 @@ def test_tag_urls_export_returns_plain_text_urls(client, api_token, tagged_data)
HTTP_HOST=ADMIN_HOST,
)
from urllib.parse import quote
from archivebox.core.tag_utils import tag_filename_safe
assert response.status_code == 200
assert response["Content-Type"].startswith("text/plain")
assert f"tag-{quote(tag.name, safe='')}-urls.txt" in response["Content-Disposition"]
assert f"tag-{tag_filename_safe(tag.name)}-urls.txt" in response["Content-Disposition"]
exported_urls = set(filter(None, response.content.decode().splitlines()))
assert exported_urls == {snapshot.url for snapshot in snapshots}