Initial commit
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
// app/api/transcoder/upload/[videoId]/route.ts
|
||||
// Receives a ZIP archive of the completed HLS package from the remote worker,
|
||||
// extracts it to disk, validates it, renames the temp dir to its final name,
|
||||
// and marks the video as transcoded.
|
||||
//
|
||||
// The request body must be raw application/zip (no multipart wrapper).
|
||||
// The file is streamed to disk before extraction – never fully buffered in RAM.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import * as fssync from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import { Readable } from "stream";
|
||||
import { pipeline } from "stream/promises";
|
||||
import * as unzipper from "unzipper";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { verifyTranscoderToken } from "@/lib/transcoder-auth";
|
||||
|
||||
// Allow up to 45 minutes for very large uploads.
|
||||
export const maxDuration = 2700;
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? "/uploads";
|
||||
const HLS_ROOT = path.join(UPLOADS_DIR, "hls");
|
||||
|
||||
const SAFE_ID = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ videoId: string }> }
|
||||
) {
|
||||
if (!verifyTranscoderToken(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { videoId } = await context.params;
|
||||
|
||||
if (!SAFE_ID.test(videoId)) {
|
||||
return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const video = await prisma.video.findUnique({ where: { id: videoId } });
|
||||
if (!video) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (video.transcodingStatus !== "processing") {
|
||||
return NextResponse.json(
|
||||
{ error: "Video is not in processing state" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
await fs.mkdir(HLS_ROOT, { recursive: true });
|
||||
|
||||
const tempZipPath = path.join(HLS_ROOT, `${videoId}.incoming.zip`);
|
||||
const tempDir = path.join(HLS_ROOT, `${videoId}.tmp`);
|
||||
const finalDir = path.join(HLS_ROOT, videoId);
|
||||
|
||||
// Security: path traversal guard.
|
||||
for (const p of [tempZipPath, tempDir, finalDir]) {
|
||||
if (!path.resolve(p).startsWith(path.resolve(HLS_ROOT))) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!request.body) {
|
||||
return NextResponse.json({ error: "Empty request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Stream upload body to a temp zip file on disk.
|
||||
const nodeReadable = Readable.fromWeb(
|
||||
request.body as ReadableStream<Uint8Array>
|
||||
);
|
||||
const writeStream = fssync.createWriteStream(tempZipPath);
|
||||
await pipeline(nodeReadable, writeStream);
|
||||
|
||||
// 2. Prepare extraction directory.
|
||||
if (fssync.existsSync(tempDir)) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
// 3. Stream-extract the zip.
|
||||
await fssync
|
||||
.createReadStream(tempZipPath)
|
||||
.pipe(unzipper.Extract({ path: tempDir }))
|
||||
.promise();
|
||||
|
||||
// 4. Remove temp zip.
|
||||
await fs.unlink(tempZipPath).catch(() => {});
|
||||
|
||||
// 5. Validate contents.
|
||||
const masterPath = path.join(tempDir, "master.m3u8");
|
||||
if (!fssync.existsSync(masterPath)) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
return NextResponse.json(
|
||||
{ error: "master.m3u8 not found in archive" },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const files = await fs.readdir(tempDir);
|
||||
const hasVariant = files.some(
|
||||
(f) => f.endsWith(".m3u8") && f !== "master.m3u8"
|
||||
);
|
||||
if (!hasVariant) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
return NextResponse.json(
|
||||
{ error: "No variant playlist found in archive" },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Remove any stale final directory and atomically rename.
|
||||
if (fssync.existsSync(finalDir)) {
|
||||
await fs.rm(finalDir, { recursive: true, force: true });
|
||||
}
|
||||
await fs.rename(tempDir, finalDir);
|
||||
|
||||
// 7. Mark as transcoded in the database.
|
||||
await prisma.video.update({
|
||||
where: { id: videoId },
|
||||
data: { transcodingStatus: "transcoded" },
|
||||
});
|
||||
|
||||
console.log(`[Transcoder Upload] ${videoId} – success (${files.length} files)`);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error(`[Transcoder Upload] Error for ${videoId}:`, err);
|
||||
|
||||
// Best-effort cleanup.
|
||||
await fs.unlink(tempZipPath).catch(() => {});
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Upload processing failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user