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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-10 14:47:52 +02:00
co-authored by Claude Opus 5
parent b3256457d2
commit 94f8ed4db9
2 changed files with 84 additions and 4 deletions
+35 -3
View File
@@ -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)},
)
+49 -1
View File
@@ -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")