feat: Bild-Upload fuer Inhalte, Produkte und Ernten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m34s

Echter Datei-Upload statt CSS-Platzhaltergrafiken, gespeichert unter
UPLOAD_DIR (Laufzeit-Verzeichnis, weiss nichts von seiner tatsaechlichen
Speicher-Anbindung — in Produktion ein von der Betreiberin selbst per
Kubernetes eingebundenes Volume, z.B. SMB; lokal ein simples ./uploads).

- lib/uploads.ts: Kernlogik. Bildformat wird ausschliesslich aus den
  tatsaechlichen Bilddaten erkannt (Magic-Bytes via sharp), nie aus
  Client-Dateiname/MIME-Type. Zufaellige, nicht erratbare Dateinamen
  (Path-Traversal-/Overwrite-Schutz). Re-Encoding validiert Pixel-Daten
  vollstaendig und entfernt eingebettete Metadaten (EXIF/ICC/XMP) als
  Nebeneffekt. Content-Type beim Ausliefern kommt ausschliesslich aus
  einer festen Endung->MIME-Zuordnung, nie aus Dateiinhalt/-namen.
- app/api/admin/upload/route.ts: Admin-only Upload-Endpunkt (CSRF- +
  Auth- + Rate-Limit-Pruefung, max. 8 MB, JPEG/PNG/WebP/AVIF).
- app/uploads/[...path]/route.ts: liefert hochgeladene Bilder aus
  (public/ wird zur Build-Zeit fest ins Image gebacken, kann also keine
  zur Laufzeit hinzugefuegten Dateien bedienen) — mit
  Path-Traversal-Schutz und nosniff-Header.
- content-registry.ts: neuer "image"-Feldtyp fuer Hero-/Startseite-
  Grafiken, admin/inhalte hat dafuer ein Upload-Widget.
- Produkt-/Ernten-Admin-Formulare: Mehrfach-Bild-Upload fuer
  Product.images/Harvest.images (beide Felder existierten im Schema,
  hatten aber bislang keine Admin-Oberflaeche). Oeffentliche Seiten
  (Shop, Ernten, Chargen-QR-Seite) zeigen jetzt echte Fotos statt
  Platzhalter, sobald vorhanden.
- Kubernetes: nur ein einfacher volumeMount in 05-app-deployment.yaml
  (Platzhalter-emptyDir, klar als CHANGE-ME markiert) — die eigentliche
  Volume-Quelle (SMB o.ae.) bindet die Betreiberin selbst ein, wie
  gewuenscht keine SMB/CSI-spezifischen Manifeste hinzugefuegt.

Verifiziert: tsc --noEmit clean, 139/139 Tests gruen (21 neu), pnpm
build clean. Upload-/Serving-Pipeline live gegen einen laufenden
Server getestet (Auth-/CSRF-Gates, Path-Traversal-Ablehnung,
Format-Ablehnung) — ein vollstaendiger authentifizierter Upload
end-to-end (braucht echten Admin-Login inkl. 2FA) wurde nicht live
durchgespielt, nur per Code-Review verifiziert.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-04 19:59:42 +02:00
1 parent 3de9f2906e
commit 54919d82ed
35 files changed
+1014 -44

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { getCurrentAdmin } from "@/lib/auth";
import type { Customer } from "@/generated/prisma/client";
/**
* Admin-Autorisierung fuer Route-Handler (app/api/admin/**\/route.ts).
* requireAdmin() in admin/actions/_shared.ts ist fuer Server Actions
* geschrieben (wirft + Next.js redirect) — ein Route-Handler ist ein
* normaler POST-/GET-Endpunkt und muss stattdessen eine normale Response
* zurueckgeben. Dieselbe autoritative Pruefung wie requireAdmin() (Rolle +
* 2FA-Pflicht direkt gegen die Datenbank, nicht nur das JWT-Claim).
*
* Aufrufer pruefen `"response" in result` und geben diese Response direkt
* zurueck, sonst `result.admin` verwenden.
*/
export async function requireAdminForApi(): Promise<
{ admin: Customer } | { response: NextResponse }
> {
const admin = await getCurrentAdmin();
if (!admin) {
return { response: NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 }) };
}
if (!admin.twoFactorEnabled) {
return {
response: NextResponse.json(
{ error: "Zwei-Faktor-Authentifizierung ist für Admin-Konten erforderlich." },
{ status: 403 }
),
};
}
return { admin };
}
+106
View File
@@ -0,0 +1,106 @@
import { NextResponse } from "next/server";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
import { isTrustedOrigin } from "@/lib/csrf";
import { rateLimit } from "@/lib/rate-limit";
import { logAdminAction } from "@/lib/audit-log";
import {
UPLOAD_DIR,
MAX_UPLOAD_BYTES,
extensionForFormat,
generateUploadFilename,
reencodeImage,
} from "@/lib/uploads";
import { requireAdminForApi } from "../_shared";
// node:fs braucht die Node-Runtime (nicht Edge) — ohnehin der Next.js-
// Standard fuer Route-Handler, hier bewusst explizit dokumentiert.
export const runtime = "nodejs";
/**
* Admin-only Upload-Endpunkt fuer Bilder (Inhalte-/Produkt-/Erntenfotos,
* siehe lib/content-registry.ts "image"-Feldtyp bzw. admin/produkte,
* admin/ernten). Nimmt genau eine Bilddatei per multipart/form-data
* entgegen, validiert sie ueber ihre tatsaechlichen Bilddaten (nicht ueber
* Client-Dateiname/MIME-Type), re-enkodiert sie (validiert Pixel-Daten
* vollstaendig + entfernt eingebettete Metadaten als Nebeneffekt, siehe
* lib/uploads.ts) und speichert sie unter einem zufaelligen Dateinamen
* unter UPLOAD_DIR. Liefert den oeffentlichen Pfad zurueck, unter dem
* app/uploads/[...path]/route.ts die Datei ausliefert.
*/
export async function POST(request: Request) {
// Route-Handler haben keinen eingebauten CSRF-Schutz — siehe lib/csrf.ts.
// Ohne diese Pruefung koennte eine fremde Seite, waehrend ein Admin
// eingeloggt ist, per gefaelschtem Cross-Site-POST beliebige Dateien in
// UPLOAD_DIR schreiben.
if (!isTrustedOrigin(request)) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
}
const auth = await requireAdminForApi();
if ("response" in auth) return auth.response;
const { admin } = auth;
if (!rateLimit(`admin-upload:${admin.id}`, 30, 60 * 60 * 1000)) {
return NextResponse.json(
{ error: "Zu viele Uploads. Bitte versuche es später erneut." },
{ status: 429 }
);
}
const formData = await request.formData().catch(() => null);
const file = formData?.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Keine Datei übermittelt." }, { status: 400 });
}
if (file.size === 0) {
return NextResponse.json({ error: "Die Datei ist leer." }, { status: 400 });
}
if (file.size > MAX_UPLOAD_BYTES) {
return NextResponse.json(
{ error: `Die Datei ist zu groß (max. ${Math.floor(MAX_UPLOAD_BYTES / (1024 * 1024))} MB).` },
{ status: 413 }
);
}
const buffer = Buffer.from(await file.arrayBuffer());
// Bildformat ausschliesslich aus den tatsaechlichen Bilddaten erkennen
// (Magic-Bytes via sharp) — nie aus dem Client-Dateinamen oder dem
// vom Client gesendeten Content-Type, beide sind frei faelschbar.
let format: string | undefined;
try {
format = (await sharp(buffer).metadata()).format;
} catch {
return NextResponse.json({ error: "Die Datei ist kein gültiges Bild." }, { status: 400 });
}
const extension = extensionForFormat(format);
if (!extension) {
return NextResponse.json(
{ error: "Nicht unterstütztes Bildformat. Erlaubt: JPEG, PNG, WebP, AVIF." },
{ status: 400 }
);
}
let output: Buffer;
try {
output = await reencodeImage(buffer, extension);
} catch {
return NextResponse.json({ error: "Das Bild konnte nicht verarbeitet werden." }, { status: 400 });
}
await mkdir(UPLOAD_DIR, { recursive: true });
const filename = generateUploadFilename(extension);
await writeFile(path.join(UPLOAD_DIR, filename), output);
await logAdminAction(admin, {
action: "media.upload",
entityType: "Upload",
entityId: filename,
detail: `${extension}, ${Math.ceil(output.length / 1024)} KB`,
});
return NextResponse.json({ path: `/uploads/${filename}` });
}