mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-05 22:32:50 +03:00
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
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:
+20
-1
@@ -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"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user