Kaynağa Gözat

fix(http): RFC 6266-encode Content-Disposition so non-ASCII filenames don't crash response (issue #1245)

  Reported by @1000Delta. The printer file download (and three sibling
  endpoints) raised UnicodeEncodeError: 'latin-1' codec can't encode
  characters... on any filename outside U+0000..U+00FF (Chinese,
  Japanese, Arabic, accented Latin), because the route pushed `filename`
  straight into Content-Disposition: attachment; filename="...".
  Starlette/uvicorn encodes response headers as latin-1, so the assignment
  crashed at write-time.

  New backend/app/utils/http.py::build_content_disposition emits both an
  ASCII-stripped legacy filename="..." fallback and an RFC 5987
  filename*=UTF-8''<percent-encoded> parameter. Every modern browser
  prefers the *= form, so the original Unicode filename round-trips
  through Save-As intact.

  Same shape was latent in three siblings and fixed in the same PR
  (no deferred follow-ups): archive QR endpoint (archive.print_name
  from 3MF metadata), project ZIP export (project.name — the existing
  isalnum() sanitiser passes non-ASCII through), and the PDF label
  streamer (latent today, callers ASCII-only but the helper hardens it).
maziggy 2 hafta önce
ebeveyn
işleme
3f58fc74b4

Dosya farkı çok büyük olduğundan ihmal edildi
+ 1 - 0
CHANGELOG.md


+ 5 - 3
backend/app/api/routes/archives.py

@@ -27,6 +27,7 @@ from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
+from backend.app.utils.http import build_content_disposition
 from backend.app.utils.threemf_tools import (
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
@@ -633,7 +634,7 @@ async def export_archives(
     return StreamingResponse(
         io.BytesIO(file_bytes),
         media_type=content_type,
-        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+        headers={"Content-Disposition": build_content_disposition(filename)},
     )
 
 
@@ -672,7 +673,7 @@ async def export_stats(
     return StreamingResponse(
         io.BytesIO(file_bytes),
         media_type=content_type,
-        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+        headers={"Content-Disposition": build_content_disposition(filename)},
     )
 
 
@@ -2343,10 +2344,11 @@ async def get_qrcode(
     pil_img.save(buffer, format="PNG")
     buffer.seek(0)
 
+    qr_filename = f"qr_{archive.print_name or archive_id}.png"
     return Response(
         content=buffer.getvalue(),
         media_type="image/png",
-        headers={"Content-Disposition": f'inline; filename="qr_{archive.print_name or archive_id}.png"'},
+        headers={"Content-Disposition": build_content_disposition(qr_filename, disposition="inline")},
     )
 
 

+ 2 - 1
backend/app/api/routes/labels.py

@@ -30,6 +30,7 @@ from backend.app.models.spool import Spool
 from backend.app.models.user import User
 from backend.app.services.label_renderer import LabelData, TemplateName, render_labels
 from backend.app.services.spoolman import get_spoolman_client
+from backend.app.utils.http import build_content_disposition
 
 logger = logging.getLogger(__name__)
 
@@ -126,7 +127,7 @@ def _stream_pdf(pdf: bytes, filename: str) -> StreamingResponse:
         io.BytesIO(pdf),
         media_type="application/pdf",
         headers={
-            "Content-Disposition": f'inline; filename="{filename}"',
+            "Content-Disposition": build_content_disposition(filename, disposition="inline"),
             "Content-Length": str(len(pdf)),
             # PDFs are deterministic per request; tell the browser not to cache
             # so re-printing after edits picks up the new data.

+ 2 - 1
backend/app/api/routes/printers.py

@@ -45,6 +45,7 @@ from backend.app.services.printer_manager import (
     supports_chamber_temp,
     supports_drying,
 )
+from backend.app.utils.http import build_content_disposition
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -1057,7 +1058,7 @@ async def download_printer_file(
     return Response(
         content=data,
         media_type=content_type,
-        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+        headers={"Content-Disposition": build_content_disposition(filename)},
     )
 
 

+ 2 - 1
backend/app/api/routes/projects.py

@@ -40,6 +40,7 @@ from backend.app.schemas.project import (
     ProjectUpdate,
     TimelineEvent,
 )
+from backend.app.utils.http import build_content_disposition
 
 logger = logging.getLogger(__name__)
 
@@ -1651,7 +1652,7 @@ async def export_project(
     return StreamingResponse(
         zip_buffer,
         media_type="application/zip",
-        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+        headers={"Content-Disposition": build_content_disposition(filename)},
     )
 
 

+ 17 - 0
backend/app/utils/http.py

@@ -0,0 +1,17 @@
+"""HTTP response helpers."""
+
+from urllib.parse import quote
+
+
+def build_content_disposition(filename: str, disposition: str = "attachment") -> str:
+    """Build an RFC 6266-compliant Content-Disposition header value.
+
+    Starlette/uvicorn encodes response headers as latin-1, so any non-ASCII
+    character in a raw `filename="..."` parameter raises UnicodeEncodeError.
+    The fix is RFC 5987's `filename*=UTF-8''<percent-encoded>` form alongside
+    a stripped ASCII fallback in the legacy `filename="..."` parameter — every
+    modern browser prefers the `*` form when present.
+    """
+    ascii_fallback = filename.encode("ascii", "ignore").decode("ascii").strip(" ._-") or "download"
+    ascii_fallback = ascii_fallback.replace('"', "").replace("\\", "")
+    return f"{disposition}; filename=\"{ascii_fallback}\"; filename*=UTF-8''{quote(filename)}"

+ 48 - 0
backend/tests/integration/test_printers_api.py

@@ -4,6 +4,7 @@ Tests the full request/response cycle for /api/v1/printers/ endpoints.
 """
 
 from unittest.mock import AsyncMock, MagicMock, patch
+from urllib.parse import unquote
 
 import pytest
 from httpx import AsyncClient
@@ -231,6 +232,53 @@ class TestPrintersAPI:
 
         assert response.status_code == 404
 
+    # ========================================================================
+    # File download endpoint — non-ASCII filename regression (#1245)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        ("filename", "ascii_fallback"),
+        [
+            ("龙泡泡石墩子_p2s_ok.gcode.3mf", "p2s_ok.gcode.3mf"),
+            ("こんにちは.gcode.3mf", "gcode.3mf"),
+            ("résumé.gcode.3mf", "rsum.gcode.3mf"),
+            ("مرحبا.gcode.3mf", "gcode.3mf"),
+            ("文件.3mf", "3mf"),
+            ("hello.3mf", "hello.3mf"),
+        ],
+    )
+    async def test_download_printer_file_non_ascii_filename(
+        self,
+        async_client: AsyncClient,
+        printer_factory,
+        filename: str,
+        ascii_fallback: str,
+        db_session,
+    ):
+        """Non-ASCII filenames must not crash header encoding (issue #1245)."""
+        printer = await printer_factory()
+        file_bytes = b"fake 3mf content"
+
+        with patch(
+            "backend.app.api.routes.printers.download_file_bytes_async",
+            new=AsyncMock(return_value=file_bytes),
+        ):
+            response = await async_client.get(
+                f"/api/v1/printers/{printer.id}/files/download",
+                params={"path": f"/cache/{filename}"},
+            )
+
+        assert response.status_code == 200
+        assert response.content == file_bytes
+
+        content_disposition = response.headers["content-disposition"]
+        assert f'filename="{ascii_fallback}"' in content_disposition
+        assert "filename*=UTF-8''" in content_disposition
+        encoded_name = content_disposition.split("filename*=UTF-8''", 1)[1]
+        assert unquote(encoded_name) == filename
+
     # ========================================================================
     # Status endpoint
     # ========================================================================

+ 71 - 0
backend/tests/unit/test_http_utils.py

@@ -0,0 +1,71 @@
+"""Unit tests for backend.app.utils.http."""
+
+from urllib.parse import unquote
+
+import pytest
+
+from backend.app.utils.http import build_content_disposition
+
+
+@pytest.mark.parametrize(
+    ("filename", "expected_ascii_fallback"),
+    [
+        ("hello.gcode.3mf", "hello.gcode.3mf"),
+        ("龙泡泡石墩子_p2s_ok.gcode.3mf", "p2s_ok.gcode.3mf"),
+        ("こんにちは.gcode.3mf", "gcode.3mf"),
+        ("résumé.gcode.3mf", "rsum.gcode.3mf"),
+        ("مرحبا.gcode.3mf", "gcode.3mf"),
+        ("文件.3mf", "3mf"),
+        ("模型.gcode.3mf", "gcode.3mf"),
+        ("project_2026-05-08.zip", "project_2026-05-08.zip"),
+        ("___.zip", "zip"),
+        ("", "download"),
+    ],
+)
+def test_ascii_fallback_strips_non_ascii(filename: str, expected_ascii_fallback: str) -> None:
+    header = build_content_disposition(filename)
+    assert f'filename="{expected_ascii_fallback}"' in header
+
+
+@pytest.mark.parametrize(
+    "filename",
+    [
+        "龙泡泡石墩子_p2s_ok.gcode.3mf",
+        "こんにちは.gcode.3mf",
+        "résumé.gcode.3mf",
+        "مرحبا.gcode.3mf",
+        "文件.3mf",
+        "hello world (final).pdf",
+        "你好/世界.pdf",
+    ],
+)
+def test_filename_star_round_trips_to_original(filename: str) -> None:
+    header = build_content_disposition(filename)
+    assert "filename*=UTF-8''" in header
+    encoded = header.split("filename*=UTF-8''", 1)[1]
+    assert unquote(encoded) == filename
+
+
+def test_header_is_latin1_encodable() -> None:
+    """Starlette/uvicorn encodes response headers as latin-1 — the helper's
+    output MUST round-trip through latin-1 without raising."""
+    for filename in [
+        "龙泡泡石墩子_p2s_ok.gcode.3mf",
+        "こんにちは.gcode.3mf",
+        "résumé.gcode.3mf",
+        "مرحبا.gcode.3mf",
+        '"quoted"name.zip',
+        "back\\slash.zip",
+    ]:
+        header = build_content_disposition(filename)
+        header.encode("latin-1")
+
+
+def test_disposition_param_is_respected() -> None:
+    assert build_content_disposition("foo.pdf", disposition="inline").startswith("inline; ")
+    assert build_content_disposition("foo.pdf").startswith("attachment; ")
+
+
+def test_quotes_and_backslashes_stripped_from_ascii_fallback() -> None:
+    header = build_content_disposition('a"b\\c.pdf')
+    assert 'filename="abc.pdf"' in header

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor