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:
co-authored by
Claude Opus 5
parent
b3256457d2
commit
94f8ed4db9
@@ -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)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user