From 94f8ed4db9a077dcc5b57f2b5b980adb1ecfdb6f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 10 Oct 2026 14:47:52 +0200 Subject: [PATCH] Etikettenvorlage: der Umlaut aus macOS sprengte den Download macOS legt Dateinamen zerlegt ab - das "ae" in "Lagerplaetze.lbx" ist dort nicht ein Zeichen, sondern "a" plus ein kombinierender Trema-Akzent. Genau diese Form landet beim Hochladen in der Datenbank. Starlette schreibt Header aber als latin-1, und fuer den Akzent gibt es dort nichts - die Route starb mit UnicodeEncodeError und antwortete 500, obwohl der Name am Bildschirm voellig harmlos aussah. Der Header wird jetzt nach RFC 6266 zweigleisig gebaut: filename= traegt eine ASCII-Fassung als Rueckfall, filename*= den echten Namen prozentkodiert. Browser bevorzugen filename* und zeigen den Umlaut richtig. Derselbe Riegel liegt auf dem CSV-Download, wo der frei getippte Vorlagenname in den Header geht - gleicher Defekt, nur noch nicht aufgefallen. Fuer die Download-Route gab es bisher keinen Test; zwei kommen dazu. Co-Authored-By: Claude Opus 5 --- backend/app/routers/etiketten.py | 38 ++++++++++++++++++++++-- backend/tests/test_etiketten.py | 50 +++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/backend/app/routers/etiketten.py b/backend/app/routers/etiketten.py index 81faba2..30cf16b 100644 --- a/backend/app/routers/etiketten.py +++ b/backend/app/routers/etiketten.py @@ -14,7 +14,9 @@ from __future__ import annotations import io import json import re +import unicodedata import zipfile +from urllib.parse import quote from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status from sqlalchemy.orm import Session @@ -55,6 +57,34 @@ def _safe_filename(name: str | None) -> str: return cleaned[:255] or "vorlage.lbx" +def _disposition(name: str | None) -> str: + """``Content-Disposition`` fuer einen Dateinamen aus einem Upload. + + Starlette kodiert Header-Werte als latin-1. Ein Dateiname kommt aber vom + Dateisystem des Hochladenden und darf alles enthalten, was dort erlaubt + ist: macOS legt ein "ä" ZERLEGT ab, als "a" + U+0308, und fuer diesen + kombinierenden Akzent gibt es keine latin-1-Entsprechung. Roh eingesetzt + sprengt so ein Name die Kodierung – die Route antwortet dann mit 500 + statt mit der Datei, obwohl der Name am Bildschirm voellig harmlos + aussieht (".../Lagerplätze.lbx" von einem Mac ist genau dieser Fall). + + Deshalb nach RFC 6266 zweigleisig: ``filename`` traegt eine ASCII-Fassung + als Rueckfall, ``filename*`` den echten Namen prozentkodiert. Browser + bevorzugen ``filename*`` und zeigen damit die Umlaute richtig an. + """ + sicher = _safe_filename(name) + # NFKD zerlegt "ä" in "a" + Akzent, "ignore" wirft dann nur den Akzent + # weg: "Lagerplätze.lbx" wird zu "Lagerplatze.lbx" und bleibt lesbar. + ascii_fassung = ( + unicodedata.normalize("NFKD", sicher).encode("ascii", "ignore").decode("ascii") + ) + # Ein Name ganz ohne ASCII-Anteil (z. B. rein kyrillisch) bliebe sonst leer. + return ( + f'attachment; filename="{ascii_fassung.strip() or "vorlage.lbx"}"; ' + f"filename*=UTF-8''{quote(sicher, safe='')}" + ) + + def _pruefe_lbx(dateiname: str, daten: bytes) -> None: """Ist das plausibel eine P-touch-Vorlage? @@ -242,11 +272,13 @@ def vorlage_csv( """ vorlage = _vorlage_oder_404(db, vorlage_id) inhalt = csv_erzeugen(db, vorlage, basis_url=basis_url) - dateiname = _safe_filename(f"{vorlage.name}.csv".replace("/", "-")) + dateiname = f"{vorlage.name}.csv".replace("/", "-") return Response( content=inhalt.encode("utf-8"), media_type="text/csv; charset=utf-8", - headers={"Content-Disposition": f'attachment; filename="{dateiname}"'}, + # Derselbe Riegel wie beim .lbx-Download: Der Vorlagenname ist frei + # getippt und kann genauso Zeichen tragen, die latin-1 nicht kennt. + headers={"Content-Disposition": _disposition(dateiname)}, ) @@ -283,7 +315,7 @@ def datei_herunterladen( # attachment und nicht inline: Die Datei soll gespeichert werden, der # Browser kann mit ihr ohnehin nichts anfangen. media_type="application/octet-stream", - headers={"Content-Disposition": f'attachment; filename="{vorlage.datei_name}"'}, + headers={"Content-Disposition": _disposition(vorlage.datei_name)}, ) diff --git a/backend/tests/test_etiketten.py b/backend/tests/test_etiketten.py index 7309abe..e6b9b96 100644 --- a/backend/tests/test_etiketten.py +++ b/backend/tests/test_etiketten.py @@ -6,7 +6,9 @@ deshalb ohne HTTP, direkt am Ergebnis. import io import json +import unicodedata import zipfile +from urllib.parse import unquote import pytest from fastapi import HTTPException @@ -23,7 +25,7 @@ from app.models import ( Role, User, ) -from app.routers.etiketten import _pruefe_lbx, spalten +from app.routers.etiketten import _pruefe_lbx, datei_herunterladen, spalten from app.services.etiketten import csv_erzeugen, konfiguration_lesen @@ -351,3 +353,49 @@ def test_vorlage_uebersteht_die_sicherung(db, admin, kabel): neue_fd = zweite.query(FieldDefinition).filter(FieldDefinition.label == "Länge").one() assert feld_spalte == f"feld:{neue_fd.id}" zweite.close() + + +# -------------------------------------------------------------------------- +# Download der hinterlegten .lbx +# -------------------------------------------------------------------------- +def _disposition_pruefen(antwort, erwarteter_name): + """Header muss sich senden lassen UND den echten Namen tragen.""" + roh = antwort.headers["content-disposition"] + # Starlette kodiert Header als latin-1. Wirft das hier, antwortet die + # Route im Betrieb mit 500 statt mit der Datei. + roh.encode("latin-1") + stern = roh.split("filename*=UTF-8''", 1)[1] + assert unquote(stern) == erwarteter_name + + +def test_lbx_mit_zerlegtem_umlaut_laesst_sich_herunterladen(db, admin): + """macOS legt Dateinamen ZERLEGT ab: das "ä" ist dort "a" + U+0308. + + Am Bildschirm sieht das wie die zusammengesetzte Form aus, aber nur die + passt in latin-1 - die Kodierung, mit der HTTP-Header geschrieben werden. + Ein Name, den der Dateiwaehler eines Macs liefert, darf den Download + nicht sprengen. + """ + name = unicodedata.normalize("NFD", "Lagerplätze.lbx") + v = _vorlage(db, ["uid"]) + v.datei_name = name + v.datei = _zip(["label.xml"]) + db.commit() + + antwort = datei_herunterladen(vorlage_id=v.id, db=db, _=admin) + + assert antwort.body == _zip(["label.xml"]) + _disposition_pruefen(antwort, name) + + +def test_lbx_mit_schlichtem_namen_bleibt_unveraendert(db, admin): + """Der haeufige Fall darf von der Umstellung nicht beruehrt werden.""" + v = _vorlage(db, ["uid"]) + v.datei_name = "kabel.lbx" + v.datei = _zip(["label.xml"]) + db.commit() + + antwort = datei_herunterladen(vorlage_id=v.id, db=db, _=admin) + + assert 'filename="kabel.lbx"' in antwort.headers["content-disposition"] + _disposition_pruefen(antwort, "kabel.lbx")