import { createRouter } from '@@/create-router'; import { resolve, dirname, join, sep, parse as parsePath } from 'node:path'; import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { getOwnerHomeDir, DATA_PATH } from '@@/data-path'; import { resolveHomeDir } from '@@/user-home'; import { writeAtomic } from '@@/write-atomic'; import * as errors from '@@/custom-errors'; import { readTtsConfig } from '@@/api/server-settings/tts'; import { readOcrConfig } from '@@/api/server-settings/ocr'; import { getUserSettings } from 'officerdb'; import { buildThumbnail, clampSize, thumbnailKind } from './thumbnails'; async function getUserTtsVoice(userId: number): Promise { try { const settings = (await getUserSettings(userId)) as { tts?: { voice?: string | null } }; return settings.tts?.voice ?? null; } catch {} return null; } const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video']; async function cleanOldCacheDirs(userDataDir: string) { for (const dir of OLD_CACHE_DIRS) { const target = join(userDataDir, dir); if (existsSync(target)) await rm(target, { recursive: true, force: true }); } } // `seedHomeDir` used to be here, creating Downloads/Documents/Music/Videos/Pictures on the first listing of // any home. Removed 2026-08-11: it invented folders in somebody's home directory as a side effect of LOOKING // at it, which is not a listing's business and not a layout the platform has any standing to choose. export const router = createRouter(); /** * Resolve whose home this request may touch, once, before any handler runs. * * A middleware rather than a change to `getRootDir`'s signature because that function is called from * fifteen places in this file. Making it async would have meant editing fifteen call sites, and the * failure mode of missing one is the worst available: a handler that quietly serves the OWNER'S home to a * member. Resolving here means a handler cannot run without the answer. * * The `user-data` root is untouched by this — it is already keyed on the caller's own email and holds * platform-written data rather than anything executable. */ router.use(async (ctx, next) => { const user = ctx.get('user'); const resolved = await resolveHomeDir(user.id as number); if (!resolved.ok) { throw resolved.needsOsAccount ? errors.FORBIDDEN(`Files are not available for this account: ${resolved.reason}.`) : errors.FORBIDDEN(resolved.reason); } ctx.set('user', { ...user, homeDir: resolved.home }); return next(); }); /** * `homeDir` is put on the context user by `confineToHome` below, so the fifteen-odd call sites of * `getRootDir` keep working unchanged and none of them can forget to resolve it. */ type UserCtx = { email: string; homeDir?: string }; function getUserDataDir(email: string): string { return join(DATA_PATH, email); } export function getRootDir(user: UserCtx, root?: string): string { // `user.homeDir` is set for every request that reached a handler — the middleware refuses the request // otherwise. The fallback exists only for the owner-shaped callers that construct a UserCtx by hand; // it is NOT a "member without an OS account gets the owner's home" path, because such a request never // gets this far. See user-home.ts for why that distinction is the whole point. if (!root || root === 'home') return user.homeDir ?? getOwnerHomeDir(user.email); if (root === 'user-data') return getUserDataDir(user.email); throw errors.BAD_REQUEST(`Invalid root: ${root}`); } // A resolved path counts as contained only when it IS the root or sits beneath it. A bare // startsWith also accepts a sibling whose name merely begins with the root's — `/home/br-backup` // passes a `/home/br` check — which is how `..` segments escaped. const isInside = (root: string, target: string): boolean => target === root || target.startsWith(root + sep); export function resolveUserPath(rootDir: string, relPath: string): string { const resolved = resolve(rootDir, relPath.replace(/^\/+/, '')); if (!isInside(rootDir, resolved)) throw errors.FORBIDDEN('Path outside root directory'); return resolved; } // mkv `title` / mp4 `handler_name` hold a track's name; the generic "…Handler" defaults are ignored. function trackName(tags?: { title?: string; handler_name?: string }): string { const handler = tags?.handler_name ?? ''; return tags?.title || (handler && !/Handler$/.test(handler) ? handler : ''); } // ffprobe bit_rate (bits/s, as a string) → rounded kbps, or null when the container doesn't report it. function kbps(bitRate?: string): number | null { const n = Number(bitRate); return Number.isFinite(n) && n > 0 ? Math.round(n / 1000) : null; } // Serve a video with a chosen audio track selected: fast `-c copy` remux (video untouched, other // audio dropped) cached under the user's data dir, so it streams with byte-range seeking like /raw. // In-flight remuxes are shared so concurrent requests for the same track don't race on the temp file. const audioRemuxInFlight = new Map>(); async function ensureAudioRemux(email: string, absPath: string, relPath: string, track: number): Promise { const parsed = parsePath(relPath); const ext = (parsed.ext.slice(1) || 'mp4').toLowerCase(); const sub = parsed.dir ? `${parsed.dir}/` : ''; // ffmpeg picks the output muxer from the file extension, so both the final and temp names must // keep the real extension (a ".tmp" suffix makes ffmpeg fail with "unable to choose format"). const base = resolve(getUserDataDir(email), `cache/audio/${sub}${parsed.name}.a${track}`); const cacheAbs = `${base}.${ext}`; if (existsSync(cacheAbs)) return cacheAbs; const pending = audioRemuxInFlight.get(cacheAbs); if (pending) return pending; const job = (async () => { await mkdir(dirname(cacheAbs), { recursive: true }); const tmp = `${base}.tmp.${ext}`; const movflags = ext === 'mp4' || ext === 'mov' || ext === 'm4v' ? ['-movflags', '+faststart'] : []; const proc = Bun.spawn( [ 'ffmpeg', '-v', 'error', '-i', absPath, '-map', '0:v', '-map', `0:a:${track}`, '-c', 'copy', '-dn', '-sn', ...movflags, '-y', tmp, ], { stdout: 'ignore', stderr: 'pipe' }, ); const code = await proc.exited; if (code !== 0) { const err = await new Response(proc.stderr).text(); await rm(tmp, { force: true }).catch(() => {}); throw errors.BAD_REQUEST(err.trim() || 'Audio track remux failed'); } await rename(tmp, cacheAbs); return cacheAbs; })(); audioRemuxInFlight.set(cacheAbs, job); try { return await job; } finally { audioRemuxInFlight.delete(cacheAbs); } } // List directory entries router.get('/ls', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, ''); const absPath = resolveUserPath(rootDir, relPath); // Create the home root itself if it is missing, and nothing else. A listing that invents its own contents // is a listing you cannot trust — the folder set it used to seed is gone. // // Non-fatal: a member's home is theirs, so this can raise EPERM, and `readdir` below is the real test of // whether the directory can be used. if (!ctx.req.query('root') || ctx.req.query('root') === 'home') { try { await mkdir(absPath, { recursive: true }); } catch { // Either it exists, or it is not ours to create. } } // Remove old top-level cache dirs (migrated to cache/ prefix) if (ctx.req.query('root') === 'user-data') { await cleanOldCacheDirs(rootDir); } let names: string[]; try { names = await readdir(absPath); } catch (ex) { // A missing directory resets the browser to the root, which is the right answer for a stale path. // // A PERMISSION failure is not that, and conflating them cost an afternoon: a member's home is 700 and // theirs, so before the ACL grant in os-user.ts the platform's readdir raised EACCES here and this // returned an empty listing — the UI said "This folder is empty" over five directories that existed. // An empty result is data; it should never be how a refusal looks. if ((ex as { code?: string }).code === 'EACCES' || (ex as { code?: string }).code === 'EPERM') { throw errors.FORBIDDEN( `Officer cannot read ${relPath || 'this folder'}. If this is a member's home, its access control ` + `lists are missing — reprovision the Linux account from Settings → User management.`, ); } return ctx.json({ path: '/', entries: [], reset: true }); } const entries = await Promise.all( names.map(async (name) => { const fullPath = resolve(absPath, name); // Skip entries that escape the home dir (shouldn't happen but be safe) if (!isInside(rootDir, fullPath)) return null; const s = await stat(fullPath).catch(() => null); if (!s) return null; const isDirectory = s.isDirectory(); return { name, type: isDirectory ? 'directory' : 'file', size: s.size, modifiedAt: s.mtimeMs, /* Whether `/thumb` will produce something, decided HERE so there is one list of extensions rather than one on each side of the wire. The client had no way to know without its own copy, and the copies would have drifted — the browser's `NATIVE_VIDEO_EXTS` is deliberately only the formats a