import { NextRequest, NextResponse } from "next/server"; import fs from "node:fs/promises"; import path from "node:path"; import { revalidatePath } from "next/cache"; import { assertLocalRequest } from "@/lib/api/localOnly"; import { attachManualSubtitle, detachManualSubtitle, listManualSubtitlesForVariant, } from "@/lib/video/manualSubtitles"; import { SUBTITLE_EXTS } from "@/lib/video/subtitles"; import { isAllowedSubtitlePath } from "@/lib/video/subtitleAccess"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; interface AttachBody { partIdx?: number; abs?: string; } export async function POST(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { const blocked = assertLocalRequest(req); if (blocked) return blocked; const { code } = await ctx.params; const decoded = decodeURIComponent(code); const body = (await req.json().catch(() => ({}))) as AttachBody; const partIdx = typeof body.partIdx === "number" && Number.isFinite(body.partIdx) ? Math.max(0, body.partIdx) : 0; const abs = typeof body.abs === "string" ? body.abs.trim() : ""; if (!abs) return NextResponse.json({ error: "Missing abs" }, { status: 400 }); const ext = path.extname(abs).toLowerCase(); if (!(SUBTITLE_EXTS as readonly string[]).includes(ext)) { return NextResponse.json({ error: "Unsupported subtitle extension" }, { status: 415 }); } // Containment: only attach paths that already pass the same allowlist // the track endpoint enforces (configured roots / generated-subtitles / // session-trusted via /api/pick-file). Without this check, any local // POST could persist an arbitrary on-disk path into manual_subtitles // and gain permanent read access through the track endpoint. const absResolved = path.resolve(abs); if (!isAllowedSubtitlePath(absResolved)) { return NextResponse.json({ error: "Subtitle path not allowed" }, { status: 403 }); } // Sanity-check the file is readable. Rejecting now beats silent // failure later when the picker tries to fetch the track. try { await fs.access(absResolved); } catch { return NextResponse.json({ error: "File not accessible" }, { status: 404 }); } attachManualSubtitle(decoded, partIdx, absResolved); revalidatePath("/id/[code]", "page"); return NextResponse.json({ ok: true }); } export async function DELETE(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { const blocked = assertLocalRequest(req); if (blocked) return blocked; const { code } = await ctx.params; const decoded = decodeURIComponent(code); const partRaw = req.nextUrl.searchParams.get("part"); const partIdx = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0); const abs = req.nextUrl.searchParams.get("abs") ?? ""; if (!abs) return NextResponse.json({ error: "Missing abs" }, { status: 400 }); detachManualSubtitle(decoded, partIdx, abs); return NextResponse.json({ ok: true }); } export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { const blocked = assertLocalRequest(req); if (blocked) return blocked; const { code } = await ctx.params; const decoded = decodeURIComponent(code); const partRaw = req.nextUrl.searchParams.get("part"); const partIdx = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0); return NextResponse.json({ entries: listManualSubtitlesForVariant(decoded, partIdx) }); }