08b6e58b8e
Deploy Vault CMS / deploy (push) Successful in 2m23s
- Upgrade next 16.0.3 -> 16.3.5 and react/react-dom 19.2.0 -> 19.3.0 (CVE-2025-55182 / CVE-2025-66478 unauthenticated RCE), next-auth -> 4.24.15 - Require admin role on /api/users, /api/courses and /api/enrollments, which were unauthenticated - Replace ALLOWED_ADMINS checks that allowed any signed-in user when the variable was empty with the role-based requireAdmin - Sanitize upload filenames, restrict thumbnail extensions, store edited thumbnails in UPLOADS_DIR, tighten traversal checks in file routes - Dockerfile: Node 22, pinned pnpm, non-root user, critical audit gate - Compose: read-only rootfs, dropped caps, no-new-privileges, Postgres password from env - Exclude .env from Docker build context; add security headers - Deploy with git reset --hard instead of git pull - Remove stale package-lock.json Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
4.1 KiB
TypeScript
138 lines
4.1 KiB
TypeScript
// app/api/admin/upload/finalize/route.ts
|
|
import { NextResponse } from "next/server";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { spawn } from "child_process";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { requireAdmin } from "@/lib/admin-check";
|
|
|
|
const UPLOADS_DIR = process.env.UPLOADS_DIR || "/uploads";
|
|
|
|
async function getVideoDurationFromFile(filePath: string): Promise<number | null> {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const ffprobe = spawn("ffprobe", [
|
|
"-v", "error",
|
|
"-show_entries", "format=duration",
|
|
"-of", "default=noprint_wrappers=1:nokey=1:nokey=1",
|
|
filePath,
|
|
]);
|
|
|
|
let output = "";
|
|
let timedOut = false;
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
timedOut = true;
|
|
ffprobe.kill();
|
|
resolve(null);
|
|
}, 30000);
|
|
|
|
ffprobe.stdout.on("data", (data) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
ffprobe.on("close", (code) => {
|
|
clearTimeout(timeoutId);
|
|
if (!timedOut && code === 0) {
|
|
const duration = parseFloat(output.trim());
|
|
if (!isNaN(duration) && isFinite(duration) && duration > 0) {
|
|
resolve(Math.round(duration));
|
|
} else {
|
|
resolve(null);
|
|
}
|
|
} else {
|
|
resolve(null);
|
|
}
|
|
});
|
|
|
|
ffprobe.on("error", () => {
|
|
clearTimeout(timeoutId);
|
|
resolve(null);
|
|
});
|
|
} catch (err) {
|
|
resolve(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function POST(req: Request) {
|
|
try {
|
|
console.log('[Upload/Finalize] Request received');
|
|
|
|
await requireAdmin();
|
|
|
|
const body = await req.json();
|
|
const { videoId } = body;
|
|
|
|
if (!videoId) {
|
|
return NextResponse.json({ error: "videoId required" }, { status: 400 });
|
|
}
|
|
|
|
console.log('[Upload/Finalize] Checking for video:', videoId);
|
|
|
|
// Check if video exists in DB
|
|
const video = await prisma.video.findUnique({
|
|
where: { id: videoId },
|
|
});
|
|
|
|
if (!video) {
|
|
return NextResponse.json({ error: "video not found" }, { status: 404 });
|
|
}
|
|
|
|
// Check if file exists at the expected path
|
|
const videoPath = path.join(UPLOADS_DIR, "videos", `${videoId}.mp4`);
|
|
console.log('[Upload/Finalize] Checking file at:', videoPath);
|
|
|
|
try {
|
|
await fs.promises.access(videoPath, fs.constants.F_OK);
|
|
} catch {
|
|
return NextResponse.json(
|
|
{ error: `File not found at ${videoPath}. Please copy the file there first.` },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
console.log('[Upload/Finalize] File found, extracting duration');
|
|
|
|
// Extract duration if not already set
|
|
let durationSec = video.durationSec;
|
|
if (!durationSec) {
|
|
try {
|
|
const extractedDuration = await getVideoDurationFromFile(videoPath);
|
|
if (extractedDuration !== null) {
|
|
durationSec = extractedDuration;
|
|
console.log('[Upload/Finalize] Extracted duration:', durationSec);
|
|
} else {
|
|
console.warn('[Upload/Finalize] Could not extract duration');
|
|
}
|
|
} catch (err) {
|
|
console.warn('[Upload/Finalize] Duration extraction error:', err);
|
|
}
|
|
}
|
|
|
|
// Update video: set URL, duration, and transcoding status to trigger processing
|
|
const videoUrl = `/uploads/videos/${videoId}.mp4`;
|
|
const updatedVideo = await prisma.video.update({
|
|
where: { id: videoId },
|
|
data: {
|
|
url: videoUrl,
|
|
transcodingStatus: 'uploaded', // Mark as ready for transcoding
|
|
...(durationSec !== null && { durationSec }),
|
|
},
|
|
});
|
|
|
|
console.log('[Upload/Finalize] Video updated successfully with URL:', videoUrl);
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
video: updatedVideo,
|
|
message: `Video finalized${durationSec ? ` (${durationSec}s)` : ''}. HLS transcoding will start shortly.`,
|
|
}, { status: 200 });
|
|
} catch (err: any) {
|
|
console.error("[Upload/Finalize] Error:", err);
|
|
const status = err?.status ?? 500;
|
|
const message = err?.message ?? "server error";
|
|
return NextResponse.json({ error: message }, { status });
|
|
}
|
|
}
|