a browsed folder is a path, not a file list, and what clicking one means is "everything under here" — so the expansion happens in the sidecar, off the cache, rather than making the browser walk the tree a level at a time and rebuild paths it only half knows. browse reports file names as basenames, unlike search, so the peer's real path is rejoined from the folder row. sizes come from the cache too: slskd matches a queued download on filename AND size, so a number supplied by the client would be a transfer that silently never starts. a subtree can be the peer's whole share (284k files on one measured peer), so an over-limit request is refused with its count rather than truncated into a partial download nobody asked for. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
196 lines
9.5 KiB
TypeScript
196 lines
9.5 KiB
TypeScript
import {
|
|
getSoulseekFavorites,
|
|
addSoulseekFavorite,
|
|
removeSoulseekFavorite,
|
|
getSoulseekBrowseSnapshots,
|
|
getSoulseekBrowseSnapshot,
|
|
getSoulseekBrowseLevel,
|
|
searchSoulseekBrowseTree,
|
|
getSoulseekBrowseDirFiles,
|
|
deleteSoulseekBrowse,
|
|
} from 'officerdb';
|
|
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;
|
|
}
|