Ursachen der langen Ladezeiten: Reel-Video 1,6 GB in 4K mit Index am Dateiende, Headerbild 15 MB, Vorschaubild 1,3 MB im Original. Neues Skript erzeugt Web-Fassungen (<name>.web.mp4, 1080p, faststart; verkleinerte Bilder), die Seite bevorzugt sie automatisch. Mediendateien mit ?v= werden ein Jahr (immutable) gecacht, optimierte Bilder 30 Tage. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
76 lines
3.0 KiB
TypeScript
76 lines
3.0 KiB
TypeScript
/**
|
|
* 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<string, string> = {
|
|
".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<string, string> = {
|
|
"Content-Type": type,
|
|
"Accept-Ranges": "bytes",
|
|
// Mit ?v=<Aenderungsdatum> (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) } });
|
|
}
|