/** * Auslieferung freigegebener Medien aus dem persistenten Verzeichnis * MEDIA_DIR (Kubernetes: eigenes PersistentVolume, kein emptyDir). * * Nur lesend, nur erlaubte Dateiendungen, kein Verlassen des Verzeichnisses. * Unterstuetzt Range-Anfragen, damit Videos spulbar sind. */ import { createReadStream } from "node:fs"; import { stat } from "node:fs/promises"; import path from "node:path"; import { Readable } from "node:stream"; import { env } from "@/lib/env"; const TYPES: Record = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml", ".mp4": "video/mp4", ".webm": "video/webm", ".vtt": "text/vtt", }; const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/; export async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) { const { path: segments } = await params; const notFound = new Response("Nicht gefunden", { status: 404 }); if (segments.length === 0 || segments.length > 4 || !segments.every((s) => SEGMENT.test(s) && !s.includes(".."))) return notFound; const base = path.resolve(env().MEDIA_DIR); const file = path.resolve(base, ...segments); if (!file.startsWith(base + path.sep)) return notFound; const type = TYPES[path.extname(file).toLowerCase()]; if (!type) return notFound; let info; try { info = await stat(file); } catch { return notFound; } if (!info.isFile()) return notFound; const headers: Record = { "Content-Type": type, "Accept-Ranges": "bytes", // Mit ?v= (site-media.ts) aendert sich die Adresse bei // jedem Austausch: dann ein Jahr cachen, sonst einen Tag. "Cache-Control": new URL(request.url).searchParams.has("v") ? "public, max-age=31536000, immutable" : "public, max-age=86400", "X-Content-Type-Options": "nosniff", // SVG (Logo) direkt aufgerufen: keine Skripte, keine Nachladungen. ...(type === "image/svg+xml" ? { "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox" } : {}), }; const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get("range") ?? ""); if (range && (range[1] || range[2])) { const size = info.size; let start = range[1] ? Number(range[1]) : size - Number(range[2]); let end = range[1] && range[2] ? Number(range[2]) : size - 1; start = Math.max(0, start); end = Math.min(end, size - 1); if (start > end) return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}` } }); const stream = Readable.toWeb(createReadStream(file, { start, end })) as ReadableStream; return new Response(stream, { status: 206, headers: { ...headers, "Content-Range": `bytes ${start}-${end}/${size}`, "Content-Length": String(end - start + 1) }, }); } const stream = Readable.toWeb(createReadStream(file)) as ReadableStream; return new Response(stream, { headers: { ...headers, "Content-Length": String(info.size) } }); }