85 lines
3.4 KiB
TypeScript
85 lines
3.4 KiB
TypeScript
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) });
|
|
}
|