Files
pinkudex/app/api/manual-subtitle/[code]/route.ts
T
2026-05-26 22:46:00 +02:00

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) });
}