feat: Bild-Upload fuer Inhalte, Produkte und Ernten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m34s
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:
1 parent
3de9f2906e
commit
54919d82ed
35 files changed
+1014
-44
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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}` });
|
||||
}
|
||||
Reference in new issue
Block a user