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.
196 lines
9.5 KiB
TypeScript
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;
|
|
}
|