soulseek: peer menu with browse and favorites
slskd has no favorites or buddy-list concept — 0.26.0's UsersController exposes only endpoint/browse/directory/info/status — so Officer owns that data itself: a soulseek_favorites table served by the slskd sidecar under a /_officer/* namespace, which can never collide with slskd's /api/v0/*. The main server gains exactly one line, injecting X-Officer-User on the proxy hop, so it stays a thin auth proxy and grows no Soulseek logic. The route is handled before the upstream check, so favorites keep working with slskd down. Usernames in search results and downloads become a dropdown (browse shares, toggle favorite). Browsing publishes to a nonce-stamped, consumed-once channel so the Users section looks the peer up without re-running the expensive browse on every remount, and favorites get their own section at the top of that panel, which doubles as its landing content. CardHeader had to split its toggle row to host the dropdown, since a trigger can't live inside the collapse button. The schema file is deliberately self-contained so it can move wholesale into the sidecar directory when sidecars start owning their own schema. Its DDL was applied by hand, matching drizzle's constraint naming, rather than running a whole-schema push. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,10 @@ import { getSlskdServerUrl } from './sidecar-server';
|
||||
// the subpath + query + body to the officer-slskd sidecar, which OWNS the slskd contract and injects the
|
||||
// slskd API key. The platform holds no slskd credentials.
|
||||
//
|
||||
// This is a catch-all with no routes of its own: the full /api/slskd/* contract (slskd's own API, e.g.
|
||||
// /api/v0/searches, /api/v0/transfers) is documented at the top of the sidecar's fetch handler —
|
||||
// src/servers/sidecar/slskd/index.ts.
|
||||
// This is a catch-all with no routes of its own: the full /api/slskd/* contract is documented at the top
|
||||
// of the sidecar's fetch handler (src/servers/sidecar/slskd/index.ts). That contract covers both slskd's
|
||||
// own API (/api/v0/searches, /api/v0/transfers, …) and the sidecar's Officer-owned routes (/_officer/*,
|
||||
// features slskd has no concept of). Both are opaque from here — this file never grows Soulseek logic.
|
||||
|
||||
export const slskdRouter = createRouter();
|
||||
|
||||
@@ -28,6 +29,9 @@ slskdRouter.all('/*', async (ctx) => {
|
||||
if (contentType) headers['Content-Type'] = contentType;
|
||||
const range = ctx.req.header('range');
|
||||
if (range) headers['Range'] = range;
|
||||
// Forward the authenticated user id so the sidecar can serve its own Officer-owned routes (/_officer/*,
|
||||
// e.g. favourite peers) against Postgres. The sidecar binds loopback only, so this header is trusted.
|
||||
headers['X-Officer-User'] = String(ctx.get('user').id);
|
||||
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { getSlskdBase, getSlskdApiKey, stripHopByHop } from './upstream';
|
||||
import { handleOfficerRoute } from './officer';
|
||||
|
||||
// The officer-slskd sidecar. Same philosophy as officer-vault / officer-music: a singleton process that
|
||||
// registers with the API server and OWNS a contract — here, a reverse-proxy to a self-hosted slskd
|
||||
@@ -14,6 +15,10 @@ import { getSlskdBase, getSlskdApiKey, stripHopByHop } from './upstream';
|
||||
// `X-API-Key` and pass method, path, query, headers, status and BOTH body streams through verbatim.
|
||||
// `GET /_health` is ours (probes slskd's /health), not part of the slskd contract. The server listens on
|
||||
// a random loopback port, reported to the API on connect so it can route here.
|
||||
//
|
||||
// Paths under `/_officer/` are also ours and are NOT forwarded: they're the features slskd has no concept
|
||||
// of (favourite peers, …), served straight from Postgres. See officer.ts for that contract. Keeping them
|
||||
// here rather than in the platform API is what lets the main server stay a pure proxy forever.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// TODO (next iteration): slskd streams live search results + transfer progress over SignalR hubs at
|
||||
@@ -55,6 +60,18 @@ const server = Bun.serve({
|
||||
}
|
||||
}
|
||||
|
||||
// Officer-owned routes — answered locally, never proxied (so they work even with slskd down).
|
||||
if (url.pathname.startsWith('/_officer/')) {
|
||||
try {
|
||||
const res = await handleOfficerRoute(req, url);
|
||||
if (res) return res;
|
||||
return new Response('not found', { status: 404 });
|
||||
} catch (err) {
|
||||
console.error(`[slskd] ${req.method} ${url.pathname} failed`, err);
|
||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!base) return new Response('slskd upstream not configured', { status: 503 });
|
||||
|
||||
const target = `${base}${url.pathname}${url.search}`;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } from 'officerdb';
|
||||
|
||||
// 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 peer usernames, alphabetical
|
||||
// POST /_officer/favorites { username } → { ok: true } add (idempotent)
|
||||
// DELETE /_officer/favorites?username=<u> → { ok: true } remove (no-op if absent)
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
/** 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 path = url.pathname;
|
||||
if (path !== '/_officer/favorites') return null;
|
||||
|
||||
const userId = userIdOf(req);
|
||||
if (userId === null) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 });
|
||||
|
||||
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 Response.json({ error: 'username required' }, { status: 400 });
|
||||
await addSoulseekFavorite(userId, username);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
const username = cleanUsername(url.searchParams.get('username'));
|
||||
if (!username) return Response.json({ error: 'username required' }, { status: 400 });
|
||||
await removeSoulseekFavorite(userId, username);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
return new Response('method not allowed', { status: 405 });
|
||||
}
|
||||
Reference in New Issue
Block a user