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

+12
View File
@@ -53,3 +53,15 @@ export function strOrNull(formData: FormData, key: string): string | null {
const value = str(formData, key);
return value === "" ? null : value;
}
/**
* Alle Werte eines wiederholten Formularfelds (z.B. mehrere "images"-Hidden-
* Inputs aus AdminImageListField, je Bild ein eigenes Feld mit demselben
* Namen) als getrimmtes String-Array ohne leere Eintraege.
*/
export function strArray(formData: FormData, key: string): string[] {
return formData
.getAll(key)
.map((value) => String(value).trim())
.filter(Boolean);
}
+11 -1
View File
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { CONTENT_REGISTRY } from "@/lib/content-registry";
import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin } from "./_shared";
/**
@@ -33,7 +34,16 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
} else {
const value = formData.get(def.key);
if (value === null) continue;
updates.push({ key: def.key, valueDe: String(value), valueEn: null });
const valueDe = String(value);
// Bild-Felder duerfen nur einen Pfad enthalten, den unser eigener
// Upload-Endpunkt tatsaechlich erzeugt hat (siehe lib/uploads.ts) —
// ein manipuliertes Hidden-Feld (z.B. eine externe URL oder ein
// "javascript:"-Wert) wird ignoriert statt ungeprueft gespeichert und
// spaeter als <img>/next/image src ausgeliefert zu werden.
if (def.type === "image" && valueDe !== "" && !isValidUploadPath(valueDe)) {
continue;
}
updates.push({ key: def.key, valueDe, valueEn: null });
}
}
+4 -1
View File
@@ -4,7 +4,8 @@ import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { requireAdmin, str, num } from "./_shared";
import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, str, num, strArray } from "./_shared";
export async function createHarvestAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
@@ -25,6 +26,7 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
harvestDate: new Date(str(formData, "harvestDate")),
location: str(formData, "location") || "Altenmünster",
description: str(formData, "description"),
images: strArray(formData, "images").filter(isValidUploadPath),
isPublished,
},
});
@@ -54,6 +56,7 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
harvestDate: new Date(str(formData, "harvestDate")),
location: str(formData, "location") || "Altenmünster",
description: str(formData, "description"),
images: strArray(formData, "images").filter(isValidUploadPath),
isPublished,
},
});
+4 -1
View File
@@ -6,7 +6,8 @@ import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { notifyStockAvailable } from "@/lib/stock-notifications";
import { adminProductNumbersSchema } from "@/lib/validation";
import { requireAdmin, fail, str, strOrNull } from "./_shared";
import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
export async function createProductAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
@@ -48,6 +49,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
weightGrams: numbers.weightGrams,
priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents,
images: strArray(formData, "images").filter(isValidUploadPath),
isActive: formData.get("isActive") === "on",
inventory: { create: { quantity: numbers.quantity } },
},
@@ -100,6 +102,7 @@ export async function updateProductAction(formData: FormData): Promise<void> {
weightGrams: numbers.weightGrams,
priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents,
images: strArray(formData, "images").filter(isValidUploadPath),
isActive: formData.get("isActive") === "on",
inventory: {
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
+10 -2
View File
@@ -1,6 +1,8 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import { BackLink } from "@/components/back-link";
import { AdminImageListField } from "@/components/admin-image-list-field";
import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -8,8 +10,9 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernten/[id]">) {
const t = getDictionary(await getLocale());
const { id } = await props.params;
const harvest = await prisma.harvest.findUnique({ where: { id } });
if (!harvest) notFound();
const rawHarvest = await prisma.harvest.findUnique({ where: { id } });
if (!rawHarvest) notFound();
const harvest = { ...rawHarvest, images: asStringArray(rawHarvest.images) };
const harvestDateValue = harvest.harvestDate.toISOString().slice(0, 10);
@@ -82,6 +85,11 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<AdminImageListField
name="images"
label={t.adminHarvestEditPage.imagesLabel}
initialValues={harvest.images}
/>
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isPublished" defaultChecked={harvest.isPublished} />
{t.adminHarvestEditPage.isPublishedLabel}
+15
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { AdminImageField } from "@/components/admin-image-field";
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
import { updateSiteContentAction } from "../actions";
import { getLocale } from "@/i18n/locale";
@@ -12,6 +13,10 @@ const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
"list-titled": 5,
"list-numbered": 6,
"richtext-sections": 14,
// Bild-Feld hat kein <textarea>, nutzt ROWS_BY_TYPE also nicht — Wert nur
// vorhanden, damit dieses Record<ContentFieldDef["type"], number> weiter
// vollstaendig ist.
image: 0,
};
async function loadCurrentValues(): Promise<Map<string, { valueDe: string; valueEn: string | null }>> {
@@ -61,6 +66,16 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
const label = locale === "de" ? def.labelDe : def.labelEn;
if (!def.localized) {
if (def.type === "image") {
return (
<AdminImageField
key={def.key}
name={def.key}
label={label}
initialValue={row?.valueDe ?? ""}
/>
);
}
return (
<label key={def.key} className="block text-sm">
<span className="text-ink/70">{label}</span>
+12 -1
View File
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import { BackLink } from "@/components/back-link";
import { AdminImageListField } from "@/components/admin-image-list-field";
import { updateProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -14,7 +15,11 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
include: { harvest: true, batch: true, inventory: true },
});
if (!rawProduct) notFound();
const product = { ...rawProduct, tasteTags: asStringArray(rawProduct.tasteTags) };
const product = {
...rawProduct,
tasteTags: asStringArray(rawProduct.tasteTags),
images: asStringArray(rawProduct.images),
};
return (
<div className="max-w-xl">
@@ -51,6 +56,12 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
defaultValue={product.tasteTags.join(", ")}
/>
<AdminImageListField
name="images"
label={t.adminProductEditPage.imagesLabel}
initialValues={product.images}
/>
{product.category === "HONIG" ? (
<>
<Field
+8
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { AdminImageListField } from "@/components/admin-image-list-field";
import { createProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -64,6 +65,13 @@ export default async function AdminProductNewPage() {
label={t.adminProductNewPage.tasteTagsLabel}
placeholder={t.adminProductNewPage.tasteTagsPlaceholder}
/>
<AdminImageListField
name="images"
label={t.adminProductNewPage.imagesLabel}
initialValues={[]}
/>
<Field
name="consistency"
label={t.adminProductNewPage.consistencyLabel}
+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}` });
}
+21 -4
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Container } from "@/components/ui";
import { PlaceholderImage } from "@/components/placeholder-image";
import { ContentImage } from "@/components/content-image";
import { TrustBadges } from "@/components/trust-badges";
import { ShieldIcon } from "@/components/account-ui";
import { ProductTeaserLink } from "@/components/product-card";
@@ -51,8 +51,20 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
<p className="mt-4 text-sm text-ink/60">{t.chargePage.provenanceNote(batch.code)}</p>
<div className="mt-10 grid sm:grid-cols-2 gap-6">
<PlaceholderImage tone="honey" className="aspect-square" label={`${batch.harvest.type} ${batch.harvest.year}`} />
<PlaceholderImage tone="forest" className="aspect-square" label={batch.harvest.location} />
<ContentImage
src={batch.harvest.images[0] ?? null}
alt={`${batch.harvest.type} ${batch.harvest.year}`}
tone="honey"
className="aspect-square"
label={`${batch.harvest.type} ${batch.harvest.year}`}
/>
<ContentImage
src={batch.harvest.images[1] ?? null}
alt={batch.harvest.location}
tone="forest"
className="aspect-square"
label={batch.harvest.location}
/>
</div>
<div className="mt-14">
@@ -85,7 +97,12 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
<p className="text-sm tracking-wide-caps text-ink/60 mb-6">{t.chargePage.fromThisBatch}</p>
<div className="grid sm:grid-cols-2 gap-6">
{batch.products.map((product) => (
<ProductTeaserLink key={product.id} slug={product.slug} name={product.name} />
<ProductTeaserLink
key={product.id}
slug={product.slug}
name={product.name}
image={product.images[0] ?? null}
/>
))}
</div>
</div>
+14 -3
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Link from "next/link";
import { Container } from "@/components/ui";
import { PlaceholderImage } from "@/components/placeholder-image";
import { ContentImage } from "@/components/content-image";
import { BackLink } from "@/components/back-link";
import { ProductTeaserLink } from "@/components/product-card";
import { getHarvestByYearType } from "@/lib/data";
@@ -44,7 +44,13 @@ export default async function HarvestDetailPage(
<BackLink href="/ernten" label={t.erntenDetailPage.backLabel} />
</Container>
<Container className="grid md:grid-cols-2 gap-14">
<PlaceholderImage tone="forest" className="aspect-[4/5] w-full" label={harvest.location} />
<ContentImage
src={harvest.images[0] ?? null}
alt={harvest.type}
tone="forest"
className="aspect-[4/5] w-full"
label={harvest.location}
/>
<div>
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
{harvest.year} · {formatDate(harvest.harvestDate)}
@@ -71,7 +77,12 @@ export default async function HarvestDetailPage(
<h2 className="mt-10 text-lg text-forest">{t.erntenDetailPage.productsHeading}</h2>
<div className="mt-4 grid gap-3">
{harvest.products.map((product) => (
<ProductTeaserLink key={product.id} slug={product.slug} name={product.name} />
<ProductTeaserLink
key={product.id}
slug={product.slug}
name={product.name}
image={product.images[0] ?? null}
/>
))}
</div>
</div>
+4 -2
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Container, SectionHeading } from "@/components/ui";
import { PlaceholderImage } from "@/components/placeholder-image";
import { ContentImage } from "@/components/content-image";
import { getHarvests } from "@/lib/data";
import { formatMonthYear } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
@@ -41,7 +41,9 @@ export default async function ErntenPage() {
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
className="group"
>
<PlaceholderImage
<ContentImage
src={harvest.images[0] ?? null}
alt={harvest.type}
tone="forest"
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
label={formatMonthYear(harvest.harvestDate)}
+2 -1
View File
@@ -29,7 +29,7 @@ export const dynamic = "force-dynamic";
export default async function HomePage() {
const locale = await getLocale();
const [heroContent, company] = await Promise.all([
getContentValues(["home.hero.intro", "home.hero.footnote"], locale),
getContentValues(["home.hero.intro", "home.hero.footnote", "home.hero.image"], locale),
getCompanyInfo(),
]);
const products = await getActiveProducts();
@@ -48,6 +48,7 @@ export default async function HomePage() {
intro={heroContent["home.hero.intro"]}
footnote={heroContent["home.hero.footnote"]}
location={company.location}
image={heroContent["home.hero.image"] || null}
/>
<CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} />
<OriginStory />
+67
View File
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { readFile } from "node:fs/promises";
import { contentTypeForExtension, resolveUploadPath } from "@/lib/uploads";
// node:fs braucht die Node-Runtime (nicht Edge).
export const runtime = "nodejs";
// Ein Jahr, unveraenderlich: jeder Upload bekommt einen neuen zufaelligen
// Dateinamen (siehe lib/uploads.ts generateUploadFilename) — der Inhalt
// unter einem einmal ausgelieferten Pfad aendert sich also nie mehr.
const CACHE_CONTROL = "public, max-age=31536000, immutable";
/**
* Liefert hochgeladene Bilder (Inhalte-/Produkt-/Erntenfotos) aus, die unter
* UPLOAD_DIR liegen. Next.js' statischer public/-Ordner wird zur Build-Zeit
* fest ins Docker-Image gebacken und kann keine zur Laufzeit hinzugefuegten
* Dateien ausliefern — dieser dynamische Route-Handler streamt sie
* stattdessen direkt von der Platte (in Produktion ein SMB-Mount, siehe
* UPLOAD_DIR in lib/uploads.ts).
*
* Oeffentlich erreichbar ohne Admin-Pruefung (die Bilder sind fuer die
* oeffentlichen Shop-/Homepage-Seiten bestimmt) — geschuetzt wird stattdessen
* NUR das Schreiben (app/api/admin/upload/route.ts).
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path: segments } = await params;
const resolved = resolveUploadPath(segments);
if (!resolved) {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
const extension = resolved.slice(resolved.lastIndexOf(".") + 1).toLowerCase();
// Content-Type kommt ausschliesslich aus dieser festen Zuordnung nach
// Dateiendung, nie aus dem Dateiinhalt oder -namen selbst — verhindert,
// dass eine ausgelieferte Datei je als text/html o.ae. interpretiert
// werden koennte. Zugleich der eigentliche Allow-List-Filter: nur
// Endungen, die unser eigener Upload-Handler erzeugt, werden ueberhaupt
// ausgeliefert; alles andere unter UPLOAD_DIR bleibt ueber diese Route
// unerreichbar.
const contentType = contentTypeForExtension(extension);
if (!contentType) {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
let data: Buffer;
try {
data = await readFile(resolved);
} catch {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
return new NextResponse(new Uint8Array(data), {
headers: {
"Content-Type": contentType,
"Content-Disposition": "inline",
// Verhindert, dass der Browser den deklarierten Content-Type anhand
// des Inhalts "korrigiert" (MIME-Sniffing) — zusaetzliche Absicherung
// neben der festen Content-Type-Zuordnung oben.
"X-Content-Type-Options": "nosniff",
"Cache-Control": CACHE_CONTROL,
},
});
}