Files
2026-05-26 22:46:00 +02:00

124 lines
4.7 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { spawn } from "node:child_process";
import path from "node:path";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { trustSubtitlePath } from "@/lib/video/subtitleAccess";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Open a native OS file-picker dialog and return the absolute path of
* the selected file. Mirrors /api/pick-folder. Currently scoped to
* subtitle files — when a subtitle is picked, the path is added to the
* session-trusted set so the subtitle track endpoint will serve it
* even if it lives outside any indexed video root.
*/
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const body = await req.json().catch(() => ({}));
const startPath = typeof body.start === "string" ? body.start : "";
const purpose = typeof body.purpose === "string" ? body.purpose : "subtitle";
try {
const picked = await runPicker(startPath, purpose);
if (!picked) return NextResponse.json({ path: null, cancelled: true });
const abs = path.resolve(picked);
if (purpose === "subtitle") {
trustSubtitlePath(abs);
}
return NextResponse.json({ path: abs });
} catch (e) {
return NextResponse.json({ error: (e as Error).message, path: null }, { status: 500 });
}
}
function runPicker(startPath: string, purpose: string): Promise<string | null> {
if (process.platform === "win32") return pickerWindows(startPath, purpose);
if (process.platform === "darwin") return pickerMacOS(startPath, purpose);
return pickerLinux(startPath, purpose);
}
function pickerWindows(startPath: string, purpose: string): Promise<string | null> {
// User-controlled values (startPath, filter) are passed via env vars so
// PowerShell never parses them as code. The script body itself contains
// no interpolation — only literal references to $env:PINKUDEX_PICK_*.
const filter = purpose === "subtitle"
? "Subtitle files (*.srt;*.vtt;*.ass;*.ssa)|*.srt;*.vtt;*.ass;*.ssa|All files (*.*)|*.*"
: "All files (*.*)|*.*";
const script = `
Add-Type -AssemblyName System.Windows.Forms | Out-Null
$dlg = New-Object System.Windows.Forms.OpenFileDialog
$dlg.Title = 'Pinkudex — pick a file'
$dlg.Filter = $env:PINKUDEX_PICK_FILTER
$dlg.Multiselect = $false
if ($env:PINKUDEX_PICK_START) { try { $dlg.InitialDirectory = $env:PINKUDEX_PICK_START } catch {} }
$owner = New-Object System.Windows.Forms.Form
$owner.TopMost = $true
$owner.Opacity = 0
$owner.ShowInTaskbar = $false
$result = $dlg.ShowDialog($owner)
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
Write-Output $dlg.FileName
}
`.trim();
return runProcess("powershell.exe", ["-NoProfile", "-Sta", "-Command", script], {
PINKUDEX_PICK_START: startPath,
PINKUDEX_PICK_FILTER: filter,
});
}
function pickerMacOS(startPath: string, purpose: string): Promise<string | null> {
const startClause = startPath
? ` default location (POSIX file "${startPath.replace(/"/g, '\\"')}")`
: "";
const typeClause = purpose === "subtitle"
? ` of type {"srt", "vtt", "ass", "ssa"}`
: "";
const script = `try
set f to choose file with prompt "Pinkudex — pick a file"${typeClause}${startClause}
return POSIX path of f
on error number -128
return ""
end try`;
return runProcess("osascript", ["-e", script]);
}
function pickerLinux(startPath: string, purpose: string): Promise<string | null> {
const args = ["--file-selection", "--title=Pinkudex — pick a file"];
if (purpose === "subtitle") {
args.push("--file-filter=Subtitles | *.srt *.vtt *.ass *.ssa");
args.push("--file-filter=All files | *");
}
if (startPath) args.push(`--filename=${startPath}`);
return runProcess("zenity", args).catch((e) => {
throw new Error(`Linux file pickers require zenity to be installed (${(e as Error).message})`);
});
}
function runProcess(
cmd: string,
args: string[],
extraEnv?: Record<string, string>,
): Promise<string | null> {
return new Promise((resolve, reject) => {
const env = extraEnv ? { ...process.env, ...extraEnv } : process.env;
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env });
let out = "";
let err = "";
child.stdout.on("data", (b) => { out += b.toString(); });
child.stderr.on("data", (b) => { err += b.toString(); });
child.on("error", (e) => reject(e));
child.on("close", (code) => {
if (code !== 0 && code !== 1 && code !== null) {
reject(new Error(err.trim() || `picker exited with code ${code}`));
return;
}
const trimmed = out.trim().replace(/\r/g, "");
resolve(trimmed || null);
});
});
}