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.
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user