import "server-only"; import path from "node:path"; import { getConfiguredVideoRoots } from "./index"; import { getAppSetting } from "@/lib/db/appSettings"; import { isManualSubtitlePath } from "./manualSubtitles"; /** * In-process set of subtitle paths the user picked via /api/pick-file * during this session. Covers the case where someone browses a .srt * sitting outside any indexed video root — the OS picker IS the * authorization. Entries time out after TTL_MS to bound how long an * old picked path remains servable. */ const TTL_MS = 60 * 60 * 1000; // 1 hour const trusted = new Map(); function pruneExpired(now: number): void { for (const [k, expiresAt] of trusted) { if (expiresAt <= now) trusted.delete(k); } } function normalize(p: string): string { // Path keys use the resolved + lowercased form on Windows so case // differences don't bypass the guard. POSIX is case-sensitive so we // keep original case there. const resolved = path.resolve(p); return process.platform === "win32" ? resolved.toLowerCase() : resolved; } export function trustSubtitlePath(abs: string): void { pruneExpired(Date.now()); trusted.set(normalize(abs), Date.now() + TTL_MS); } export function isSessionTrustedSubtitlePath(abs: string): boolean { const now = Date.now(); pruneExpired(now); const key = normalize(abs); const exp = trusted.get(key); if (exp == null) return false; if (exp <= now) { trusted.delete(key); return false; } return true; } function isInside(child: string, parent: string): boolean { const c = process.platform === "win32" ? path.resolve(child).toLowerCase() : path.resolve(child); const p = process.platform === "win32" ? path.resolve(parent).toLowerCase() : path.resolve(parent); if (!p) return false; if (c === p) return true; const sep = path.sep; return c.startsWith(p.endsWith(sep) ? p : p + sep); } /** * True if `abs` resolves under one of: * - a configured video root, * - a configured subtitleExtraPaths entry, * - the implicit data/generated-subtitles/ root (WhisperJAV output), * - a session-trusted pick-file path (exact match, not prefix), * - a path persisted in the manual_subtitles table (user explicitly * Browse'd it during a previous session). */ export function isAllowedSubtitlePath(abs: string): boolean { const resolved = path.resolve(abs); for (const root of getConfiguredVideoRoots()) { if (root && isInside(resolved, root)) return true; } const subRoots = getAppSetting("subtitleExtraPaths") ?? []; for (const root of subRoots) { if (root && isInside(resolved, root)) return true; } const generatedRoot = path.join(process.cwd(), "data", "generated-subtitles"); if (isInside(resolved, generatedRoot)) return true; if (isSessionTrustedSubtitlePath(resolved)) return true; if (isManualSubtitlePath(resolved)) return true; return false; }