Initial commit
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
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) });
|
||||
}
|
||||
Reference in New Issue
Block a user