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,114 @@
|
||||
import type { SlskdConfig } from './upstream';
|
||||
import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
|
||||
import { authHeaders, getSlskdConfig, invalidateSlskdConfig, normalizeBase } from './upstream';
|
||||
|
||||
// `/_config` — where the slskd daemon is and what key opens it, driven from the app rather than from a
|
||||
// shell on the server.
|
||||
//
|
||||
// The API key is WRITE-ONLY across this boundary. The GET reports the URL and whether a key is stored; it
|
||||
// has no field that could carry the key itself, masked or otherwise. The only way to change one is to send
|
||||
// a new one, which is the same shape the photos and offscale registries use.
|
||||
//
|
||||
// ONE connection, not a registry: nobody runs two Soulseek daemons. So there is no label, no id in the
|
||||
// path and no activate route — a save is an upsert over the single row, and the UI is one form.
|
||||
//
|
||||
// A save is validated against the live daemon before it is stored: a wrong URL or a rejected key is a 400
|
||||
// with the reason, not a saved row that makes every later panel fail mysteriously.
|
||||
|
||||
export type ProbeResult =
|
||||
| { ok: true; version: string | null; server: string | null }
|
||||
| { ok: false; version: string | null; error: string };
|
||||
|
||||
/**
|
||||
* Ask a daemon whether it is really there and whether the key works.
|
||||
*
|
||||
* Two calls, because they answer different questions: `/health` is unauthenticated, so a failure there means
|
||||
* the URL is wrong or slskd is down, while `/api/v0/application` failing after it succeeded means the key is
|
||||
* the problem. Collapsing them would report "daemon unreachable" for a mistyped key.
|
||||
*/
|
||||
export async function probe(cfg: SlskdConfig): Promise<ProbeResult> {
|
||||
try {
|
||||
const health = await fetch(`${cfg.base}/health`, { signal: AbortSignal.timeout(5000), redirect: 'manual' });
|
||||
if (!health.ok) return { ok: false, version: null, error: `daemon returned ${health.status}` };
|
||||
} catch (err) {
|
||||
return { ok: false, version: null, error: `could not reach the daemon (${String(err)})` };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${cfg.base}/api/v0/application`, {
|
||||
headers: { Accept: 'application/json', ...authHeaders(cfg) },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
redirect: 'manual',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const reason = res.status === 401 || res.status === 403 ? 'API key was rejected' : 'daemon returned';
|
||||
return { ok: false, version: null, error: `${reason} (${res.status})` };
|
||||
}
|
||||
const app = (await res.json()) as {
|
||||
version?: { current?: string; full?: string };
|
||||
server?: { state?: string };
|
||||
};
|
||||
return { ok: true, version: app.version?.full ?? app.version?.current ?? null, server: app.server?.state ?? null };
|
||||
} catch (err) {
|
||||
return { ok: false, version: null, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** What the browser is allowed to know about the connection. Never includes the key. */
|
||||
async function connectionState(userId: number): Promise<Response> {
|
||||
const connection = await getServiceConnection(userId, 'slskd');
|
||||
return Response.json({ configured: !!connection, connection });
|
||||
}
|
||||
|
||||
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
||||
|
||||
type ConnectionBody = { url?: unknown; apiKey?: unknown };
|
||||
|
||||
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
|
||||
|
||||
/** Save the connection: validated against the live daemon, then stored with the key encrypted. */
|
||||
async function save(req: Request, userId: number): Promise<Response> {
|
||||
const body = ((await req.json().catch(() => null)) as ConnectionBody | null) ?? {};
|
||||
const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
|
||||
// A blank key on an existing connection means "keep the stored one", so changing the URL does not need
|
||||
// the key re-typed. `undefined` is what saveServiceConnection reads as leave-alone.
|
||||
const apiKey = typeof body.apiKey === 'string' && body.apiKey.trim() ? body.apiKey.trim() : undefined;
|
||||
|
||||
if (!url) return bad('url is required');
|
||||
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
|
||||
|
||||
const current = await getServiceConnection(userId, 'slskd');
|
||||
if (!apiKey && !current?.hasSecret) return bad('an API key is required');
|
||||
|
||||
// Probing needs the actual key, so a URL-only save borrows the stored one through the normal read path
|
||||
// rather than reaching for the ciphertext here.
|
||||
const stored = apiKey ? null : await getSlskdConfig(userId);
|
||||
const result = await probe({ base: url, apiKey: apiKey ?? stored?.apiKey ?? null });
|
||||
if (!result.ok) return Response.json({ error: result.error }, { status: 400 });
|
||||
|
||||
const connection = await saveServiceConnection({
|
||||
userId,
|
||||
service: 'slskd',
|
||||
url,
|
||||
secret: apiKey,
|
||||
version: result.version,
|
||||
});
|
||||
invalidateSlskdConfig(userId);
|
||||
return Response.json({ connection, server: result.server });
|
||||
}
|
||||
|
||||
/** `/_config` — GET the connection, PUT/POST to save it, DELETE to forget it. */
|
||||
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
|
||||
if (subpath && subpath !== '/') return bad('not found', 404);
|
||||
|
||||
if (req.method === 'GET') return connectionState(userId);
|
||||
if (req.method === 'POST' || req.method === 'PUT') return save(req, userId);
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
await deleteServiceConnection(userId, 'slskd');
|
||||
invalidateSlskdConfig(userId);
|
||||
return connectionState(userId);
|
||||
}
|
||||
|
||||
return bad('method not allowed', 405);
|
||||
}
|
||||
Reference in New Issue
Block a user