Files
pinkudex/lib/video/subtitleAccess.ts
T
2026-05-26 22:46:00 +02:00

82 lines
2.8 KiB
TypeScript

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<string, number>();
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;
}