diff --git a/frigate/api/export.py b/frigate/api/export.py index 27f85a2cde..6a4a6d5041 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -9,6 +9,7 @@ import zipfile from collections import deque from collections.abc import Iterator from pathlib import Path +from urllib.parse import quote import psutil from fastapi import APIRouter, Depends, Query, Request @@ -453,6 +454,22 @@ def _stream_case_archive(exports: list[Export]) -> Iterator[bytes]: yield from buffer.drain() +def _content_disposition(filename: str, ascii_fallback: str) -> str: + """Build an attachment Content-Disposition that survives non-ASCII names. + + Header values are encoded as latin-1, so a name outside that range cannot + go in filename at all. RFC 6266 handles this with a pair: a plain ASCII + filename for old clients, plus a percent-encoded UTF-8 filename* that + every current browser prefers. + """ + ascii_name = filename if filename.isascii() else ascii_fallback + + return ( + f'attachment; filename="{ascii_name}"; ' + f"filename*=UTF-8''{quote(filename, safe='')}" + ) + + @router.get( "/cases/{case_id}/download", dependencies=[Depends(allow_any_authenticated())], @@ -495,7 +512,9 @@ def download_export_case( _stream_case_archive(exports), media_type="application/zip", headers={ - "Content-Disposition": f'attachment; filename="{archive_base}.zip"', + "Content-Disposition": _content_disposition( + f"{archive_base}.zip", f"{case_id}.zip" + ), }, ) diff --git a/frigate/test/http_api/test_http_export.py b/frigate/test/http_api/test_http_export.py index e0ceec559a..44eb0c2c4a 100644 --- a/frigate/test/http_api/test_http_export.py +++ b/frigate/test/http_api/test_http_export.py @@ -1,5 +1,7 @@ +import io import os import tempfile +import zipfile from unittest.mock import patch from frigate.jobs.export import ( @@ -1431,3 +1433,79 @@ class TestHttpExport(BaseTestHttp): ) assert response.status_code == 403 + + def test_download_export_case_with_multibyte_name(self): + """A case name outside latin-1 must not break the response headers.""" + case = ExportCase.create( + id="case_multibyte", + name="テスト事案", + description="", + created_at=10, + updated_at=10, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + video_path = os.path.join(tmpdir, "multibyte_export.mp4") + with open(video_path, "wb") as handle: + handle.write(b"video") + + Export.create( + id="export_multibyte", + camera="front_door", + name="現場カメラ", + date=100, + video_path=video_path, + thumb_path=os.path.join(tmpdir, "multibyte_export.webp"), + in_progress=False, + export_case=case, + ) + + with AuthTestClient(self.app) as client: + response = client.get(f"/cases/{case.id}/download") + + assert response.status_code == 200 + # RFC 5987/6266: the UTF-8 name rides in filename*, and a latin-1 safe + # fallback stays in filename for old clients. + assert response.headers["content-disposition"] == ( + 'attachment; filename="case_multibyte.zip"; ' + "filename*=UTF-8''%E3%83%86%E3%82%B9%E3%83%88%E4%BA%8B%E6%A1%88.zip" + ) + + archive = zipfile.ZipFile(io.BytesIO(response.content)) + assert archive.namelist() == ["現場カメラ.mp4"] + + def test_download_export_case_with_ascii_name(self): + """An ASCII case name still gets a plain, readable filename.""" + case = ExportCase.create( + id="case_ascii", + name="Burglary 2026-08", + description="", + created_at=10, + updated_at=10, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + video_path = os.path.join(tmpdir, "ascii_export.mp4") + with open(video_path, "wb") as handle: + handle.write(b"video") + + Export.create( + id="export_ascii", + camera="front_door", + name="Front door", + date=100, + video_path=video_path, + thumb_path=os.path.join(tmpdir, "ascii_export.webp"), + in_progress=False, + export_case=case, + ) + + with AuthTestClient(self.app) as client: + response = client.get(f"/cases/{case.id}/download") + + assert response.status_code == 200 + assert ( + response.headers["content-disposition"] + == 'attachment; filename="Burglary 2026-08.zip"; ' + "filename*=UTF-8''Burglary%202026-08.zip" + )