Foto-Upload: 413 beheben und Kamera stabilisieren

- nginx client_max_body_size auf 12m (Standard 1 MB loeste beim Bild-Upload
  einen 413 aus, bevor das Backend den Upload sah).
- Backend-Bildlimit auf 8 MB angehoben, bleibt unter dem nginx-Limit, damit
  Uebergroesse eine klare 400-Meldung statt eines nackten 413 ergibt.
- Web: Foto vor dem Upload clientseitig verkleinern (max 1600px, JPEG) -
  schneller, kleiner in der DB, zuverlaessig unter dem Limit.
- iOS: Kamera ueber fullScreenCover statt sheet und Schliessen deterministisch
  ueber ein Binding - behebt das "Kamera geht direkt wieder zu"-Flackern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-25 23:11:59 +02:00
parent a664410ac0
commit 5c8b46a0d5
4 changed files with 47 additions and 9 deletions

View File

@@ -18,9 +18,11 @@ from sqlalchemy.orm import Session
from ..models import Product, ProductImage
# Grosszuegig genug fuer ein Produktfoto, eng genug, dass niemand die Datenbank
# mit einer versehentlich verlinkten Datei volllaeuft.
MAX_BYTES = 2 * 1024 * 1024
# Grosszuegig genug fuer ein Produktfoto (auch ein Handy-Foto), eng genug, dass
# niemand die Datenbank mit einer versehentlich verlinkten Datei volllaeuft.
# Bleibt unter dem nginx-Limit (client_max_body_size, siehe web/nginx.conf),
# damit Uebergroesse eine klare Meldung statt eines nackten 413 ergibt.
MAX_BYTES = 8 * 1024 * 1024
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}

View File

@@ -39,8 +39,8 @@ struct ProductPhotoSection: View {
if busy { ProgressView() }
if let error { Text(error).foregroundStyle(.red).font(.callout) }
}
.sheet(isPresented: $showCamera) {
CameraPicker { image in Task { await upload(image) } }
.fullScreenCover(isPresented: $showCamera) {
CameraPicker(isPresented: $showCamera) { image in Task { await upload(image) } }
.ignoresSafeArea()
}
.onChange(of: pickerItem) { item in
@@ -108,8 +108,8 @@ struct ProductPhotoSection: View {
/// Kamera über UIImagePickerController SwiftUI hat keine eigene Kamera-Ansicht.
/// Ohne Kamera (Simulator) fällt es auf die Fotoauswahl zurück.
struct CameraPicker: UIViewControllerRepresentable {
@Binding var isPresented: Bool
var onImage: (UIImage) -> Void
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
@@ -123,6 +123,9 @@ struct CameraPicker: UIViewControllerRepresentable {
func makeCoordinator() -> Coordinator { Coordinator(self) }
// Schließen deterministisch über das Binding statt über die Umgebung
// das behebt das Flackern (Kamera geht direkt wieder zu), wenn die Ansicht
// während der Präsentation neu ausgewertet wird.
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: CameraPicker
init(_ parent: CameraPicker) { self.parent = parent }
@@ -130,10 +133,10 @@ struct CameraPicker: UIViewControllerRepresentable {
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let image = info[.originalImage] as? UIImage { parent.onImage(image) }
parent.dismiss()
parent.isPresented = false
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.dismiss()
parent.isPresented = false
}
}
}

View File

@@ -4,6 +4,11 @@ server {
root /usr/share/nginx/html;
index index.html;
# Foto-Uploads (Artikelbild, Logo/Favicon) brauchen mehr als die 1 MB, die
# nginx sonst zulaesst - sonst antwortet der Proxy mit 413, bevor das Backend
# den Upload ueberhaupt sieht.
client_max_body_size 12m;
# SPA-Routing: unbekannte Pfade auf index.html mappen
location / {
try_files $uri $uri/ /index.html;

View File

@@ -31,6 +31,33 @@ const CANONICAL_NAME = { piece: "Stück", gram: "Gramm", milliliter: "Milliliter
const KIND_LABEL = { count: "Anzahl", weight: "Gewicht", volume: "Volumen" };
const KIND_ORDER = ["count", "weight", "volume"];
// Foto vor dem Hochladen verkleinern: kleiner in der DB, schneller im Upload und
// zuverlässig unter dem Server-Limit. Bei Problemen fällt es aufs Original zurück.
function verkleinereBild(datei, maxDim = 1600, quality = 0.85) {
return new Promise((resolve) => {
if (!datei || !datei.type?.startsWith("image/")) return resolve(datei);
const url = URL.createObjectURL(datei);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const faktor = Math.min(1, maxDim / Math.max(img.width, img.height));
if (faktor >= 1 && datei.size <= 1_500_000) return resolve(datei);
const w = Math.round(img.width * faktor);
const h = Math.round(img.height * faktor);
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
canvas.getContext("2d").drawImage(img, 0, 0, w, h);
canvas.toBlob(
(blob) => resolve(blob ? new File([blob], "foto.jpg", { type: "image/jpeg" }) : datei),
"image/jpeg", quality,
);
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(datei); };
img.src = url;
});
}
export default function ProductForm() {
const confirm = useConfirm();
const toast = useToast();
@@ -142,7 +169,8 @@ export default function ProductForm() {
if (!file || isNew) return;
setError(null);
try {
await api.uploadProductImage(id, file);
const foto = await verkleinereBild(file);
await api.uploadProductImage(id, foto);
setBildVersion((v) => v + 1);
toast("Foto gespeichert.");
} catch (err) {