import { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite, getSoulseekBrowseSnapshots, getSoulseekBrowseSnapshot, getSoulseekBrowseLevel, searchSoulseekBrowseTree, getSoulseekBrowseDirFiles, deleteSoulseekBrowse, } from '../db'; import { startBrowse } from './browse'; import { enqueueFromCache, DownloadError } from './download'; // Officer-owned Soulseek routes — everything slskd itself has no concept of. These are served HERE, by // the sidecar, not forwarded upstream: the platform API stays a pure auth-and-forward proxy forever, and // slskd stays unforked. They live under the `/_officer/` prefix, mirroring `/_health`, so they can never // collide with slskd's own surface (all of which is under `/api/v0/`). // // ───────────────────────────────────────────────────────────────────────────────────────────────── // CONTRACT (reached from the browser as /api/slskd/_officer/…) // GET /_officer/favorites → string[] favourited peers, alphabetical // POST /_officer/favorites { username } → { ok: true } add (idempotent) // DELETE /_officer/favorites?username= → { ok: true } remove (no-op if absent) // // GET /_officer/browse → Snapshot[] every cached share tree // GET /_officer/browse/ → Snapshot | null one peer's cache state // POST /_officer/browse/ → 202 { started } kick off a background fetch // DELETE /_officer/browse/ → { ok: true } drop the cache // GET /_officer/browse//tree?parent=&limit=&offset= → { nodes, total } one level (roots if no parent) // GET /_officer/browse//tree/search?q=&limit= → { nodes, matched, total, truncated } // GET /_officer/browse//dirs//files → BrowsedFile[] one folder's files // POST /_officer/browse//download { path, file? } → { queued } enqueue a folder, or one file // ───────────────────────────────────────────────────────────────────────────────────────────────── // The authenticated user id arrives in X-Officer-User, injected by the platform proxy after auth. We // bind loopback only, so it's trusted — same arrangement as the music sidecar's per-user state routes. const userIdOf = (req: Request): number | null => { const n = Number(req.headers.get('x-officer-user')); return Number.isInteger(n) && n > 0 ? n : null; }; // Soulseek usernames are opaque to us and may contain spaces, so the only shapes we reject are ones // no nick can have: empty, absurdly long, or containing control characters. const USERNAME_MAX = 255; const CONTROL_CHARS = /[\u0000-\u001f\u007f]/; const cleanUsername = (v: unknown): string | null => { if (typeof v !== 'string') return null; const u = v.trim(); if (!u || u.length > USERNAME_MAX) return null; return CONTROL_CHARS.test(u) ? null : u; }; // A level, not a page: once you're inside a folder there's nothing to leaf through, so the UI asks for // the whole level when it's wide. The cap is set above the widest fan-out measured on a real peer (1,181). const DIR_PAGE_MAX = 2000; const DIR_PAGE_DEFAULT = 200; // A match cap, not a page: every match drags its ancestors along, so the response is up to ~depth times // this. Truncation is reported in the payload rather than passed off as the whole answer. const SEARCH_MATCH_MAX = 1000; const SEARCH_MATCH_DEFAULT = 300; const clampInt = (raw: string | null, fallback: number, min: number, max: number): number => { // An absent param has to be caught before Number(): `Number(null)` is 0, not NaN, so it passed the // finite check and clamped to `min` — which ran the tree search with LIMIT 1 and returned a single // match, making its siblings look like non-matches in the UI. if (raw === null || raw.trim() === '') return fallback; const n = Number(raw); if (!Number.isFinite(n)) return fallback; return Math.min(max, Math.max(min, Math.trunc(n))); }; const badRequest = (error: string) => Response.json({ error }, { status: 400 }); const notFound = () => new Response('not found', { status: 404 }); const methodNotAllowed = () => new Response('method not allowed', { status: 405 }); async function handleFavorites(req: Request, url: URL, userId: number): Promise { if (req.method === 'GET') return Response.json(await getSoulseekFavorites(userId)); if (req.method === 'POST') { const body = (await req.json().catch(() => null)) as { username?: unknown } | null; const username = cleanUsername(body?.username); if (!username) return badRequest('username required'); await addSoulseekFavorite(userId, username); return Response.json({ ok: true }); } if (req.method === 'DELETE') { const username = cleanUsername(url.searchParams.get('username')); if (!username) return badRequest('username required'); await removeSoulseekFavorite(userId, username); return Response.json({ ok: true }); } return methodNotAllowed(); } type BrowseRouteParams = { req: Request; url: URL; userId: number; segments: string[] }; async function handleBrowse({ req, url, userId, segments }: BrowseRouteParams): Promise { // /_officer/browse — every cached tree, for badging the favourites list in one request. if (segments.length === 1) { if (req.method !== 'GET') return methodNotAllowed(); return Response.json(await getSoulseekBrowseSnapshots(userId)); } const username = cleanUsername(decodeURIComponent(segments[1] ?? '')); if (!username) return badRequest('username required'); // /_officer/browse/ if (segments.length === 2) { if (req.method === 'GET') return Response.json(await getSoulseekBrowseSnapshot(userId, username)); if (req.method === 'POST') { // 202: the fetch takes minutes and outlives this request. Poll the GET for status. const started = await startBrowse(userId, username); return Response.json({ started }, { status: 202 }); } if (req.method === 'DELETE') { await deleteSoulseekBrowse(userId, username); return Response.json({ ok: true }); } return methodNotAllowed(); } // /_officer/browse//tree — one level of folders, paged. No `parent` means the roots. if (segments[2] === 'tree' && segments.length === 3) { if (req.method !== 'GET') return methodNotAllowed(); const level = await getSoulseekBrowseLevel({ userId, username, parent: url.searchParams.get('parent') ?? undefined, limit: clampInt(url.searchParams.get('limit'), DIR_PAGE_DEFAULT, 1, DIR_PAGE_MAX), offset: clampInt(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER), }); return Response.json(level); } // /_officer/browse//tree/search — matches plus their ancestors, as one small tree. Kept off // the level route because the shape differs: this one is a skeleton spanning depths, not a page. if (segments[2] === 'tree' && segments.length === 4 && segments[3] === 'search') { if (req.method !== 'GET') return methodNotAllowed(); const result = await searchSoulseekBrowseTree({ userId, username, q: url.searchParams.get('q') ?? '', limit: clampInt(url.searchParams.get('limit'), SEARCH_MATCH_DEFAULT, 1, SEARCH_MATCH_MAX), }); return Response.json(result); } // /_officer/browse//download — a folder's whole subtree, or one file inside it. if (segments[2] === 'download' && segments.length === 3) { if (req.method !== 'POST') return methodNotAllowed(); const body = (await req.json().catch(() => null)) as { path?: unknown; file?: unknown } | null; const path = typeof body?.path === 'string' ? body.path.trim() : ''; if (!path) return badRequest('path required'); const file = typeof body?.file === 'string' && body.file.trim() ? body.file : undefined; try { const queued = await enqueueFromCache({ userId, username, path, file }); return Response.json({ queued }); } catch (err) { if (err instanceof DownloadError) return Response.json({ error: err.message }, { status: err.status }); throw err; } } if (segments[2] !== 'dirs') return notFound(); // /_officer/browse//dirs//files if (segments.length === 5 && segments[4] === 'files') { if (req.method !== 'GET') return methodNotAllowed(); const dirId = Number(segments[3]); if (!Number.isInteger(dirId) || dirId <= 0) return badRequest('invalid directory id'); const files = await getSoulseekBrowseDirFiles({ userId, username, dirId }); return files ? Response.json(files) : notFound(); } return notFound(); } /** Handles a `/_officer/*` request, or returns null if the path isn't one of ours. */ export async function handleOfficerRoute(req: Request, url: URL): Promise { const segments = url.pathname .replace(/^\/_officer\//, '') .split('/') .filter(Boolean); if (!segments.length) return null; const userId = userIdOf(req); if (userId === null) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 }); if (segments[0] === 'favorites' && segments.length === 1) return handleFavorites(req, url, userId); if (segments[0] === 'browse') return handleBrowse({ req, url, userId, segments }); return null; }