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 { 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 { 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 { 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 { 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); }