82 lines
2.8 KiB
TypeScript
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;
|
|
}
|