diff --git a/archivebox/api/v1_core.py b/archivebox/api/v1_core.py index b5ca4281..8e517f14 100644 --- a/archivebox/api/v1_core.py +++ b/archivebox/api/v1_core.py @@ -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 diff --git a/archivebox/core/tag_utils.py b/archivebox/core/tag_utils.py index d27e7f56..4815ec3b 100644 --- a/archivebox/core/tag_utils.py +++ b/archivebox/core/tag_utils.py @@ -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" diff --git a/archivebox/templates/admin/core/tag/change_list.html b/archivebox/templates/admin/core/tag/change_list.html index ffb2a952..5f4f8204 100644 --- a/archivebox/templates/admin/core/tag/change_list.html +++ b/archivebox/templates/admin/core/tag/change_list.html @@ -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 { diff --git a/archivebox/tests/test_tag_admin.py b/archivebox/tests/test_tag_admin.py index bcda61ea..e39b1538 100644 --- a/archivebox/tests/test_tag_admin.py +++ b/archivebox/tests/test_tag_admin.py @@ -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}