fix export case download with non-ascii names (#24100)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s

This commit is contained in:
Josh Hawkins
2026-08-26 14:42:34 -06:00
committed by GitHub
parent 5197881ef7
commit ca18b8dc13
2 changed files with 98 additions and 1 deletions
+20 -1
View File
@@ -9,6 +9,7 @@ import zipfile
from collections import deque from collections import deque
from collections.abc import Iterator from collections.abc import Iterator
from pathlib import Path from pathlib import Path
from urllib.parse import quote
import psutil import psutil
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
@@ -453,6 +454,22 @@ def _stream_case_archive(exports: list[Export]) -> Iterator[bytes]:
yield from buffer.drain() 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( @router.get(
"/cases/{case_id}/download", "/cases/{case_id}/download",
dependencies=[Depends(allow_any_authenticated())], dependencies=[Depends(allow_any_authenticated())],
@@ -495,7 +512,9 @@ def download_export_case(
_stream_case_archive(exports), _stream_case_archive(exports),
media_type="application/zip", media_type="application/zip",
headers={ headers={
"Content-Disposition": f'attachment; filename="{archive_base}.zip"', "Content-Disposition": _content_disposition(
f"{archive_base}.zip", f"{case_id}.zip"
),
}, },
) )
+78
View File
@@ -1,5 +1,7 @@
import io
import os import os
import tempfile import tempfile
import zipfile
from unittest.mock import patch from unittest.mock import patch
from frigate.jobs.export import ( from frigate.jobs.export import (
@@ -1431,3 +1433,79 @@ class TestHttpExport(BaseTestHttp):
) )
assert response.status_code == 403 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"
)