Files
officer c11f45c033 soulseek, extracted from the platform as a plugin
Seventh plugin out, and the first that was already dead. Soulseek had been
switched off since 2026-08-13: hono.ts had the router import and its mount
commented out, so createSidecarProxy never ran, registerProxiedPrefix was never
called, the slskd:server announcement fired into a void, and officer-slskd was
not in the ecosystem at all. The permission entry had been reduced to api: [],
which is why assertPermissionTotality was satisfied and the server booted
clean. The UI rendered perfectly and every request 404'd.

So this is a resurrection as well as a move.

The mount is now /api/soulseek, derived via mountPrefix from the appName — all
47 frontend call sites repointed from /slskd/. The credential key in
service_connections stays 'slskd': it names the daemon, not the route. Those
two strings differ on purpose and conflating them reads the wrong row.

The connection stays in core service_connections, as transmission does, so
'slskd' remains in a platform union this plugin cannot extend. Recorded in
PLUGIN.md. Unlike transmission this plugin does own a schema: favourites and
the two halves of the cached browse tree, which slskd has no concept of. Those
three tables had been dropped from the drizzle barrel, so bun db:push would
have deleted them on any server holding data.

313 palette classes converted to the semantic tone set, including a shared
PHASE_TONE the three transfer views previously each copied.
2026-08-19 02:55:02 +00:00

196 lines
9.5 KiB
TypeScript

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=<u> → { ok: true } remove (no-op if absent)
//
// GET /_officer/browse → Snapshot[] every cached share tree
// GET /_officer/browse/<u> → Snapshot | null one peer's cache state
// POST /_officer/browse/<u> → 202 { started } kick off a background fetch
// DELETE /_officer/browse/<u> → { ok: true } drop the cache
// GET /_officer/browse/<u>/tree?parent=&limit=&offset= → { nodes, total } one level (roots if no parent)
// GET /_officer/browse/<u>/tree/search?q=&limit= → { nodes, matched, total, truncated }
// GET /_officer/browse/<u>/dirs/<id>/files → BrowsedFile[] one folder's files
// POST /_officer/browse/<u>/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<Response> {
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<Response> {
// /_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/<username>
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/<username>/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/<username>/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/<username>/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/<username>/dirs/<id>/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<Response | null> {
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;
}