Files
twotalesanimation ec125261e4
Deploy Vault CMS / deploy (push) Successful in 1m15s
Slim the Docker image and clean up after every deploy
- Build Next.js as a standalone server and copy only that into the
  runtime image, instead of the whole node_modules tree
- Mark three dynamic file reads with turbopackIgnore so the standalone
  trace does not pull in the entire project
- Deploy workflow prunes unused images and build cache older than a day
  at the start and end of every run, including failed ones, and logs
  disk usage before building

Failed builds were leaving their layers behind until the server's disk
filled, which broke the deploy, the database and Gitea sign-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:43:34 +02:00

203 lines
6.7 KiB
TypeScript

// app/uploads/videos/[...path]/route.ts
// Serves uploaded MP4s from UPLOADS_DIR/videos at the /uploads/videos/* URLs
// stored in the database. Streams from disk with HTTP Range support — browsers
// require range requests for <video> seeking, and multi-GB files must never be
// buffered in memory.
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { Readable } from "stream";
import { prisma } from "@/lib/prisma";
import { checkVideoAccess, getViewer } from "@/lib/video-access";
const VIDEOS_DIR = path.join(process.env.UPLOADS_DIR || "/uploads", "videos");
const CONTENT_TYPES: Record<string, string> = {
".mp4": "video/mp4",
".m4v": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".mkv": "video/x-matroska",
};
function resolveVideoPath(segments: string[]): string | null {
if (segments.length === 0) return null;
const requested = path.resolve(VIDEOS_DIR, ...segments);
// Security: prevent directory traversal attacks
if (!requested.startsWith(path.resolve(VIDEOS_DIR) + path.sep)) return null;
return requested;
}
async function statFile(filePath: string): Promise<fs.Stats | null> {
try {
const stats = await fs.promises.stat(filePath);
return stats.isFile() ? stats : null;
} catch {
return null;
}
}
// Parses "bytes=start-end" | "bytes=start-" | "bytes=-suffix".
// Returns null for absent/invalid headers (caller serves the full file) and
// "unsatisfiable" when the range is outside the file (caller returns 416).
function parseRange(
header: string | null,
size: number
): { start: number; end: number } | "unsatisfiable" | null {
if (!header) return null;
const m = header.match(/^bytes=(\d*)-(\d*)$/);
if (!m || (m[1] === "" && m[2] === "")) return null;
let start: number;
let end: number;
if (m[1] === "") {
// suffix range: last N bytes
const suffix = parseInt(m[2], 10);
if (suffix === 0) return "unsatisfiable";
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = parseInt(m[1], 10);
end = m[2] === "" ? size - 1 : Math.min(parseInt(m[2], 10), size - 1);
}
if (start >= size || start > end) return "unsatisfiable";
return { start, end };
}
// Maps a requested file to the Video row it belongs to, then applies the same
// access rule as the video API. Files with no Video row are admin-only.
const VIDEO_ID_TTL_MS = 5 * 60_000;
const videoIdByPath = new Map<string, { expires: number; videoId: string | null }>();
async function findVideoId(segments: string[]): Promise<string | null> {
const relative = segments.join("/");
const hit = videoIdByPath.get(relative);
if (hit && hit.expires > Date.now()) return hit.videoId;
const byUrl = await prisma.video.findFirst({
where: { url: `/uploads/videos/${relative}` },
select: { id: true },
});
// Uploads are normally stored as {videoId}.mp4
const byName =
byUrl ??
(await prisma.video.findUnique({
where: { id: path.basename(relative, path.extname(relative)) },
select: { id: true },
}));
const videoId = byName?.id ?? null;
if (videoIdByPath.size >= 5000) videoIdByPath.clear();
videoIdByPath.set(relative, { expires: Date.now() + VIDEO_ID_TTL_MS, videoId });
return videoId;
}
async function authorize(segments: string[]): Promise<{ status: number; error: string } | null> {
const viewer = await getViewer();
if (!viewer) return { status: 401, error: "Unauthorized" };
if (viewer.isAdmin) return null;
const videoId = await findVideoId(segments);
if (!videoId) return { status: 404, error: "Not found" };
const access = await checkVideoAccess(viewer, videoId);
return access.ok ? null : { status: access.status, error: access.error };
}
function baseHeaders(filePath: string, stats: fs.Stats): Record<string, string> {
const ext = path.extname(filePath).toLowerCase();
return {
"Content-Type": CONTENT_TYPES[ext] ?? "application/octet-stream",
"Accept-Ranges": "bytes",
"Cache-Control": "private, max-age=3600",
"Last-Modified": stats.mtime.toUTCString(),
};
}
export async function GET(
request: Request,
context: { params: Promise<{ path?: string[] }> }
) {
try {
const params = await context.params;
const filePath = resolveVideoPath(params?.path || []);
if (!filePath) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const denied = await authorize(params?.path || []);
if (denied) {
return NextResponse.json({ error: denied.error }, { status: denied.status });
}
const stats = await statFile(filePath);
if (!stats) {
console.log(`[Videos] File not found: ${filePath}`);
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const headers = baseHeaders(filePath, stats);
const range = parseRange(request.headers.get("range"), stats.size);
if (range === "unsatisfiable") {
return new NextResponse(null, {
status: 416,
headers: { ...headers, "Content-Range": `bytes */${stats.size}` },
});
}
if (range) {
const stream = fs.createReadStream(/*turbopackIgnore: true*/ filePath, {
start: range.start,
end: range.end,
});
return new NextResponse(Readable.toWeb(stream) as unknown as BodyInit, {
status: 206,
headers: {
...headers,
"Content-Range": `bytes ${range.start}-${range.end}/${stats.size}`,
"Content-Length": String(range.end - range.start + 1),
},
});
}
const stream = fs.createReadStream(/*turbopackIgnore: true*/ filePath);
return new NextResponse(Readable.toWeb(stream) as unknown as BodyInit, {
status: 200,
headers: { ...headers, "Content-Length": String(stats.size) },
});
} catch (error) {
console.error("[Videos] Error serving video file:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function HEAD(
request: Request,
context: { params: Promise<{ path?: string[] }> }
) {
try {
const params = await context.params;
const filePath = resolveVideoPath(params?.path || []);
if (!filePath) return new NextResponse(null, { status: 400 });
const denied = await authorize(params?.path || []);
if (denied) return new NextResponse(null, { status: denied.status });
const stats = await statFile(filePath);
if (!stats) return new NextResponse(null, { status: 404 });
return new NextResponse(null, {
status: 200,
headers: {
...baseHeaders(filePath, stats),
"Content-Length": String(stats.size),
},
});
} catch (error) {
console.error("[Videos] Error in HEAD request:", error);
return new NextResponse(null, { status: 500 });
}
}