soulseek, extracted into plugins/soulseek — the platform half
Seventh extraction, and the first of something that was already dead. Soulseek had been switched off since 2026-08-13: hono.ts carried the router import and its PROTECTED_MOUNTS entry commented out, so createSidecarProxy never ran and registerProxiedPrefix was never called; officer-slskd was not in the ecosystem at all. Because the permission entry had been reduced to api: [], the totality check was satisfied and the server booted clean — the UI rendered and every request 404'd. Worth naming: the boot check proves every MOUNTED prefix has a permission, and is silent about a feature that mounts nothing. Also closes a live data hazard: schema.ts had the soulseek export commented out, so the three tables were absent from the drizzle barrel and bun db:push would have dropped soulseek_favorites, soulseek_browse_snapshots and soulseek_browse_dirs on any server holding them. rebuild-soulseek-tree.ts documented the workaround rather than the problem; deleted with the rest. The connection stays in core service_connections under service 'slskd' while the mount is now /api/soulseek. Those differ on purpose. Test fixtures: slskd was canonical in three app-store tests, the third extraction running to break them. installer.test.ts now builds a literal fixture instead of reaching into the catalogue — it pins planSteps, which is installer logic, and no catalogue entry is both provisioned and members:'none' any more. catalogue.test.ts and members.test.ts re-anchored to vnc/notify/gitea. Unrelated but mine: check:theme caught 5 palette classes I introduced in CommandHQ earlier tonight against a baseline of 0. Converted to bg-success / bg-warning. check:theme is not part of bun test, which is why the green suite never showed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
|
||||
// /api/slskd/* — auth, then forward to officer-slskd. No routes of its own and no slskd knowledge:
|
||||
// this file must never grow app logic.
|
||||
//
|
||||
// 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; the sidecar injects
|
||||
// the slskd API key. The full contract is documented at the top of src/servers/sidecar/slskd/index.ts.
|
||||
|
||||
const proxy = createSidecarProxy({
|
||||
name: 'slskd',
|
||||
prefix: '/api/slskd',
|
||||
});
|
||||
|
||||
export const slskdRouter = proxy.router;
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||
export const getSlskdServerUrl = proxy.getHttpUrl;
|
||||
@@ -107,10 +107,11 @@ describe('member provisioning is declared for every entry', () => {
|
||||
});
|
||||
|
||||
it('never promises accounts for a single-tenant daemon', () => {
|
||||
// slskd has no user concept; claiming otherwise would make the installer try to create accounts
|
||||
// against an API that does not exist. (Transmission was the other example until 2026-08-18, when it
|
||||
// became `plugins/transmission/`.)
|
||||
for (const id of ['slskd']) expect(byId(id)!.members).toBe('none');
|
||||
// A daemon with no user concept; claiming otherwise would make the installer try to create accounts
|
||||
// against an API that does not exist. Transmission was an example until 2026-08-18 and slskd until
|
||||
// 2026-08-19, when they became `plugins/transmission/` and `plugins/soulseek/` — hence the list
|
||||
// rather than a single id: it must stay non-empty, or this assertion passes by iterating nothing.
|
||||
for (const id of ['vnc', 'notify', 'gitea']) expect(byId(id)!.members).toBe('none');
|
||||
});
|
||||
|
||||
// A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become
|
||||
|
||||
@@ -226,27 +226,6 @@ export const CATALOGUE: CatalogueEntry[] = [
|
||||
// Deleting the entry is REQUIRED, not tidying — see the note where photos used to sit, and the trap in
|
||||
// `EXTRACTING-A-PLUGIN.md`. A leftover entry makes the permission permanently `unavailable`, because
|
||||
// `permissionAvailability()` derives from `sidecar_installs` and a plugin never gets a row there.
|
||||
{
|
||||
id: 'slskd',
|
||||
ui: {
|
||||
name: 'Soulseek',
|
||||
image: '/plugins/slskd/icon.png',
|
||||
color: '#ffffff',
|
||||
rootRoute: '/soulseek',
|
||||
routes: ['/soulseek'],
|
||||
},
|
||||
process: 'officer-slskd',
|
||||
label: 'Soulseek',
|
||||
summary: 'slskd — search and download from the Soulseek network',
|
||||
members: 'none',
|
||||
modes: ['existing', 'provisioned'],
|
||||
permission: 'soulseek',
|
||||
composeTemplate: 'slskd',
|
||||
existingFields: [
|
||||
{ key: 'url', label: 'slskd URL', type: 'url', required: true },
|
||||
{ key: 'secret', label: 'API key', type: 'secret', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'caldav',
|
||||
ui: {
|
||||
|
||||
@@ -7,7 +7,13 @@ import { byId } from './catalogue';
|
||||
// when something goes wrong: resume, blocking, and not doing work twice.
|
||||
|
||||
const jellyfin = byId('jellyfin')!; // existing|provisioned, members: 'accounts'
|
||||
const slskd = byId('slskd')!; // existing|provisioned, members: 'none'
|
||||
// A LITERAL, not `byId(...)`. This was `byId('slskd')` until slskd became `plugins/soulseek/`, and
|
||||
// before that the same assertion had to be re-pointed when photos and transmission left. No catalogue
|
||||
// entry today is both `provisioned` and `members: 'none'`, so there is nothing left to borrow — and
|
||||
// borrowing was the mistake anyway: this pins `planSteps`, which is installer logic, and it should not
|
||||
// break because an unrelated service was extracted. Shaped from jellyfin so the rest of the entry stays
|
||||
// realistic.
|
||||
const noMemberConcept = { ...byId('jellyfin')!, id: 'test-single-tenant', members: 'none' as const };
|
||||
const configOnly = byId('notify')!; // config only
|
||||
|
||||
/** Records what was actually called, so a test can assert on absence as well as presence. */
|
||||
@@ -46,7 +52,7 @@ describe('planSteps', () => {
|
||||
it('omits the members step for a service with no user concept', () => {
|
||||
// Otherwise a Transmission install reports a members step that did nothing, which reads as a
|
||||
// silent failure to anyone debugging why a member has no access.
|
||||
expect(planSteps(slskd, 'provisioned')).not.toContain('members');
|
||||
expect(planSteps(noMemberConcept, 'provisioned')).not.toContain('members');
|
||||
expect(planSteps(jellyfin, 'provisioned')).toContain('members');
|
||||
});
|
||||
|
||||
|
||||
@@ -12,13 +12,13 @@ describe('what adding a member would do', () => {
|
||||
it('predicts the outcome per service without contacting anything', () => {
|
||||
expect(plannedOutcome(byId('jellyfin')!)).toBe('provisioned');
|
||||
expect(plannedOutcome(inviteOnly)).toBe('invited');
|
||||
expect(plannedOutcome(byId('slskd')!)).toBe('not-applicable');
|
||||
expect(plannedOutcome(byId('vnc')!)).toBe('not-applicable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('which installed services have work to do', () => {
|
||||
it('skips single-tenant daemons entirely', () => {
|
||||
const ids = servicesNeedingMemberProvision(['jellyfin', 'slskd', 'memos']).map((e) => e.id);
|
||||
const ids = servicesNeedingMemberProvision(['jellyfin', 'vnc', 'memos']).map((e) => e.id);
|
||||
expect(ids.sort()).toEqual(['jellyfin', 'memos']);
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import { devProxyRouter } from './api/dev-proxy/router';
|
||||
import { pluginsRouter } from './api/plugins/router';
|
||||
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||
// import { slskdRouter } from './api/slskd/router';
|
||||
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
||||
// import { jellyfinRouter } from './api/jellyfin/router';
|
||||
// import { walletRouter } from './api/wallet/router';
|
||||
@@ -141,7 +140,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/api-keys', apiKeysRouter], // your own keys; the `account` core permission covers it
|
||||
['/dashboards', dashboardsRouter],
|
||||
['/file-browser', fileBrowserRouter],
|
||||
// ['/slskd', slskdRouter], // plugin — switched off 2026-08-13
|
||||
['/terminal', terminalRouter],
|
||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||
// ['/gitea', giteaRouter], // plugin — switched off 2026-08-13
|
||||
|
||||
@@ -200,15 +200,6 @@ const CORE_REGISTRY: Permission[] = [
|
||||
// Device registration is the caller's own — a phone subscribing to its own push channel.
|
||||
personal: ['/devices', '/subscriptions'],
|
||||
},
|
||||
{
|
||||
key: 'soulseek',
|
||||
label: 'Soulseek',
|
||||
description: 'Search and download from the Soulseek network',
|
||||
kind: 'app',
|
||||
// api: ['/slskd'], // plugin — switched off 2026-08-13
|
||||
api: [],
|
||||
routes: ['/soulseek'],
|
||||
},
|
||||
{
|
||||
key: 'invoices',
|
||||
label: 'Invoices',
|
||||
|
||||
@@ -149,6 +149,27 @@ const BUNDLED: CatalogueEntry[] = [
|
||||
// `schema: false` — the connection lives in `service_connections`, a core table. See its manifest.
|
||||
has: { api: true, schema: false, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'soulseek',
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'Soulseek',
|
||||
summary: 'Search and download from the Soulseek network, via your own slskd daemon',
|
||||
color: '#22c55e',
|
||||
icon: 'Music2',
|
||||
source: 'https://gitea.officer.dev/plugins/soulseek.git',
|
||||
permissions: [
|
||||
{
|
||||
key: 'soulseek',
|
||||
label: 'Soulseek',
|
||||
description: 'Search and download from the Soulseek network',
|
||||
},
|
||||
],
|
||||
// `schema: true` unlike transmission: the CONNECTION lives in core `service_connections`, but the
|
||||
// favourites and the cached browse tree are this plugin's own three tables.
|
||||
has: { api: true, schema: true, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'vault',
|
||||
publisher: 'officerdev',
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { BrowseDirInput, BrowseDirRow, BrowsedFile } from 'officerdb';
|
||||
import { startSoulseekBrowse, finishSoulseekBrowse, failSoulseekBrowse } from 'officerdb';
|
||||
import { authHeaders, getSlskdConfig } from './upstream';
|
||||
|
||||
// Background share-tree fetches.
|
||||
//
|
||||
// slskd's GET /users/{u}/browse is a single blocking call that inlines every file of every folder —
|
||||
// measured at 59 MB / 18k folders / 284k files for one real peer, and it can take minutes because it
|
||||
// round-trips to that peer over the Soulseek network. Doing it from the browser means a tab that hangs
|
||||
// and throws the result away on navigation.
|
||||
//
|
||||
// So it runs HERE instead: the route kicks this off and returns immediately, the fetch outlives the
|
||||
// request (and the tab), and the result lands in Postgres for the UI to page through. The sidecar is a
|
||||
// long-lived PM2 process, which is what makes "come back later and it's there" work at all.
|
||||
|
||||
// slskd's wire shape. Files carry `attributes` (bitrate/duration) that real peers leave empty, plus
|
||||
// `code`/`attributeCount` noise; all of it is dropped at ingest.
|
||||
type SlskdBrowseFile = { filename: string; size?: number; extension?: string };
|
||||
type SlskdBrowseDir = { name: string; fileCount?: number; files?: SlskdBrowseFile[] };
|
||||
type SlskdBrowsePayload = { directories?: SlskdBrowseDir[] } | SlskdBrowseDir[];
|
||||
|
||||
// One browse per (user, peer) at a time. A second click while one is running is a no-op rather than a
|
||||
// duplicate multi-minute fetch of the same tree.
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
const keyOf = (userId: number, username: string) => `${userId}:${username}`;
|
||||
|
||||
/** Peers currently being fetched, so a caller can report "already running" without racing the map. */
|
||||
export const isBrowseRunning = (userId: number, username: string): boolean => inFlight.has(keyOf(userId, username));
|
||||
|
||||
// Generous, but not unbounded: a peer that accepts the connection and then stalls shouldn't pin a
|
||||
// snapshot in 'pending' forever.
|
||||
const BROWSE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
const extensionOf = (file: SlskdBrowseFile): string => {
|
||||
const raw = file.extension?.trim();
|
||||
if (raw) return raw.replace(/^\./, '').toLowerCase();
|
||||
const dot = file.filename.lastIndexOf('.');
|
||||
return dot > 0 ? file.filename.slice(dot + 1).toLowerCase() : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip the response to what the UI actually renders. Locked directories are skipped: they're shares the
|
||||
* peer won't serve us, so listing them would only offer downloads that always fail.
|
||||
*/
|
||||
function slim(payload: SlskdBrowsePayload): BrowseDirInput[] {
|
||||
const dirs = Array.isArray(payload) ? payload : (payload?.directories ?? []);
|
||||
return dirs.map((dir) => {
|
||||
const files: BrowsedFile[] = (dir.files ?? []).map((f) => ({
|
||||
// Browse gives a basename only, unlike search results' full remote path. Downloading one means
|
||||
// rejoining it to the directory name with a backslash.
|
||||
name: f.filename,
|
||||
size: f.size ?? 0,
|
||||
extension: extensionOf(f),
|
||||
}));
|
||||
return { name: dir.name, files };
|
||||
});
|
||||
}
|
||||
|
||||
const SEP = '\\';
|
||||
|
||||
/** Split a slskd path into the parent path and the last segment, ignoring any trailing separator. */
|
||||
function splitPath(name: string): { parentPath: string | null; label: string; depth: number } {
|
||||
const trimmed = name.endsWith(SEP) ? name.slice(0, -1) : name;
|
||||
const cut = trimmed.lastIndexOf(SEP);
|
||||
const depth = trimmed.split(SEP).filter(Boolean).length || 1;
|
||||
// `cut > 0` not `>= 0`: a leading separator would otherwise make the parent an empty string, which is
|
||||
// neither null nor a real row, and the level query would never find it.
|
||||
return { parentPath: cut > 0 ? trimmed.slice(0, cut) : null, label: trimmed.slice(cut + 1), depth };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn slskd's flat list of paths into a linked tree, once, here — so the UI can open one level at a
|
||||
* time instead of the platform recomputing the shape on every request.
|
||||
*
|
||||
* Two things the response can't be trusted on. Ancestors: measured across two real peers (~30k folders)
|
||||
* exactly one intermediate folder was missing, so they're nearly always present, but a single gap would
|
||||
* make a whole subtree unreachable — hence `ensure` synthesizes any absent one as an empty folder.
|
||||
* Rollups: a parent's own file count is usually just cover art, so the number worth showing on a
|
||||
* collapsed row is the subtree's, and that's summed bottom-up here rather than per request.
|
||||
*/
|
||||
export function buildTree(dirs: BrowseDirInput[]): BrowseDirRow[] {
|
||||
const byName = new Map<string, BrowseDirRow>();
|
||||
|
||||
const ensure = (name: string): BrowseDirRow => {
|
||||
const existing = byName.get(name);
|
||||
if (existing) return existing;
|
||||
const { parentPath, label, depth } = splitPath(name);
|
||||
const row: BrowseDirRow = {
|
||||
name,
|
||||
parentPath,
|
||||
label,
|
||||
depth,
|
||||
childCount: 0,
|
||||
fileCount: 0,
|
||||
totalSize: 0,
|
||||
subtreeFileCount: 0,
|
||||
subtreeSize: 0,
|
||||
files: [],
|
||||
};
|
||||
// Set before recursing so a cycle-shaped path can't loop, and so the parent's own ancestors follow.
|
||||
byName.set(name, row);
|
||||
if (parentPath) ensure(parentPath);
|
||||
return row;
|
||||
};
|
||||
|
||||
for (const dir of dirs) {
|
||||
const row = ensure(dir.name);
|
||||
// Concatenated rather than assigned: a path repeated in the response would otherwise lose files.
|
||||
row.files = row.files.length ? row.files.concat(dir.files) : dir.files;
|
||||
row.fileCount = row.files.length;
|
||||
row.totalSize = row.files.reduce((n, f) => n + (f.size || 0), 0);
|
||||
}
|
||||
|
||||
const rows = [...byName.values()];
|
||||
for (const row of rows) {
|
||||
row.subtreeFileCount = row.fileCount;
|
||||
row.subtreeSize = row.totalSize;
|
||||
}
|
||||
// Deepest first, so every child is final before its parent absorbs it.
|
||||
for (const row of [...rows].sort((a, b) => b.depth - a.depth)) {
|
||||
const parent = row.parentPath ? byName.get(row.parentPath) : undefined;
|
||||
if (!parent) continue;
|
||||
parent.childCount += 1;
|
||||
parent.subtreeFileCount += row.subtreeFileCount;
|
||||
parent.subtreeSize += row.subtreeSize;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function run(userId: number, username: string, snapshotId: number): Promise<void> {
|
||||
const cfg = await getSlskdConfig(userId);
|
||||
if (!cfg) {
|
||||
await failSoulseekBrowse(snapshotId, 'slskd upstream not configured');
|
||||
return;
|
||||
}
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${cfg.base}/api/v0/users/${encodeURIComponent(username)}/browse`, {
|
||||
headers: authHeaders(cfg),
|
||||
signal: AbortSignal.timeout(BROWSE_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.text().catch(() => '')).slice(0, 200);
|
||||
throw new Error(`slskd returned ${res.status}${detail ? `: ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const dirs = slim((await res.json()) as SlskdBrowsePayload);
|
||||
const tree = buildTree(dirs);
|
||||
await finishSoulseekBrowse(snapshotId, tree);
|
||||
const files = dirs.reduce((n, d) => n + d.files.length, 0);
|
||||
// Against distinct names, not the array length, so a repeated path doesn't read as a negative.
|
||||
const synthesized = tree.length - new Set(dirs.map((d) => d.name)).size;
|
||||
console.log(
|
||||
`[slskd] browse ${username} -> ${tree.length} dirs (${synthesized} synthesized) / ${files} files in ${Date.now() - started}ms`,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[slskd] browse ${username} failed after ${Date.now() - started}ms: ${message}`);
|
||||
await failSoulseekBrowse(snapshotId, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start caching a peer's share tree and return once it's *registered*, not once it's done — the caller
|
||||
* responds 202 and the fetch continues in the background. Resolves false if one was already running.
|
||||
*/
|
||||
export async function startBrowse(userId: number, username: string): Promise<boolean> {
|
||||
const key = keyOf(userId, username);
|
||||
if (inFlight.has(key)) return false;
|
||||
|
||||
const snapshotId = await startSoulseekBrowse(userId, username);
|
||||
// Registered before awaiting anything inside run(), so two rapid clicks can't both get past the check.
|
||||
const task = run(userId, username, snapshotId).finally(() => inFlight.delete(key));
|
||||
inFlight.set(key, task);
|
||||
return true;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { getSoulseekBrowseDownload } from 'officerdb';
|
||||
import { authHeaders, getSlskdConfig } from './upstream';
|
||||
|
||||
// Enqueueing downloads out of a cached share tree.
|
||||
//
|
||||
// slskd's own enqueue endpoint takes a flat list of {filename, size}, which is fine for search results —
|
||||
// they arrive as individual files with full paths. A browsed folder is neither: it's a path, and what the
|
||||
// user means by clicking it is "everything under here". Expanding that into a file list needs the cache,
|
||||
// so it happens HERE rather than in the browser, which would otherwise have to walk the tree one level
|
||||
// request at a time and reconstruct paths it only half knows.
|
||||
|
||||
/**
|
||||
* A folder can be enormous — the root of one measured peer is 284,166 files. Enqueueing that would be a
|
||||
* misclick that takes the slskd instance down with it, so it's refused with the count rather than
|
||||
* truncated to a silent partial download.
|
||||
*/
|
||||
export const MAX_ENQUEUE = 10_000;
|
||||
|
||||
export class DownloadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type EnqueueParams = { userId: number; username: string; path: string; file?: string };
|
||||
|
||||
/** Queue one file, or every file beneath one folder, from the cached tree. Returns how many were sent. */
|
||||
export async function enqueueFromCache({ userId, username, path, file }: EnqueueParams): Promise<number> {
|
||||
const cfg = await getSlskdConfig(userId);
|
||||
if (!cfg) throw new DownloadError('slskd upstream not configured', 503);
|
||||
|
||||
const files = await getSoulseekBrowseDownload({ userId, username, path, file });
|
||||
if (!files.length) throw new DownloadError('nothing to download at that path', 404);
|
||||
if (files.length > MAX_ENQUEUE) {
|
||||
throw new DownloadError(
|
||||
`${files.length.toLocaleString()} files is over the ${MAX_ENQUEUE.toLocaleString()} limit`,
|
||||
413,
|
||||
);
|
||||
}
|
||||
|
||||
const res = await fetch(`${cfg.base}/api/v0/transfers/downloads/${encodeURIComponent(username)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...authHeaders(cfg) },
|
||||
body: JSON.stringify(files),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.text().catch(() => '')).slice(0, 200);
|
||||
throw new DownloadError(`slskd returned ${res.status}${detail ? `: ${detail}` : ''}`, 502);
|
||||
}
|
||||
|
||||
console.log(`[slskd] queued ${files.length} file(s) from ${username}: ${file ?? path}`);
|
||||
return files.length;
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { resetStaleSoulseekBrowses } from 'officerdb';
|
||||
import { getSlskdConfig, stripHopByHop } from './upstream';
|
||||
import { handleConfigRoute, probe } from './config';
|
||||
import { handleOfficerRoute } from './officer';
|
||||
import { API_URL } from '../../officer-url.mjs';
|
||||
|
||||
// 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
|
||||
// (Soulseek daemon). ALL knowledge of slskd (its URL, its API key) lives here; the platform API is a
|
||||
// thin, auth-gated forwarder that never sees the slskd credential. This lets the Soulseek client grow
|
||||
// end-to-end without the main server accreting any slskd-specific logic.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP CONTRACT — the platform strips its /api/slskd mount prefix before forwarding, so requests arrive
|
||||
// here as slskd-root paths (e.g. /api/v0/searches, /api/v0/transfers, /api/v0/session). We inject
|
||||
// `X-API-Key` and pass method, path, query, headers, status and BOTH body streams through verbatim.
|
||||
// The server listens on a random loopback port, reported to the API on connect so it can route here.
|
||||
//
|
||||
// GET /_health ours (probes slskd's /health), not part of the slskd contract
|
||||
// GET /_config { configured, connection } — the URL and whether a key is stored, never the key
|
||||
// PUT /_config { url, apiKey? } — validated against the daemon, then stored encrypted. A blank
|
||||
// apiKey on an existing connection keeps the stored one
|
||||
// DEL /_config forget the connection
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind
|
||||
// loopback only, so its presence is the trust signal — a request without it did not come through the
|
||||
// platform, and the connection is per-owner data.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// TODO (next iteration): slskd streams live search results + transfer progress over SignalR hubs at
|
||||
// /hub/*. Add a WebSocket upgrade path here mirroring the officer-vault notifications proxy
|
||||
// (src/servers/sidecar/vault/index.ts) once the client needs real-time updates. SignalR carries its
|
||||
// credential as an `?access_token=` query param on the socket, so the key injection differs from HTTP.
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
const p = probeServer.port;
|
||||
probeServer.stop(true);
|
||||
if (p == null) throw new Error('failed to acquire a free port');
|
||||
return p;
|
||||
}
|
||||
|
||||
const port = getFreePort();
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
idleTimeout: 255, // browse/search can idle; Bun caps this at 255s
|
||||
maxRequestBodySize: 1024 * 1024 * 1024, // room for uploads / large browse responses
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
const officerUser = req.headers.get('X-Officer-User');
|
||||
const userId = Number(officerUser);
|
||||
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
|
||||
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
|
||||
try {
|
||||
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
|
||||
} catch (err) {
|
||||
console.error(`[slskd] ${req.method} ${url.pathname} failed`, err);
|
||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = await getSlskdConfig(userId);
|
||||
|
||||
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
|
||||
// configured-but-broken daemon is the whole reason the flag is on the response.
|
||||
if (url.pathname === '/_health') {
|
||||
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
|
||||
const started = Date.now();
|
||||
const result = await probe(cfg);
|
||||
const ms = Date.now() - started;
|
||||
if (!result.ok) return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 });
|
||||
return Response.json({ ok: true, configured: true, version: result.version, server: result.server, ms });
|
||||
}
|
||||
|
||||
// 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 (!cfg) return Response.json({ error: 'slskd not connected', configured: false }, { status: 503 });
|
||||
|
||||
const target = `${cfg.base}${url.pathname}${url.search}`;
|
||||
const method = req.method;
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
const started = Date.now();
|
||||
|
||||
const headers = stripHopByHop(req.headers);
|
||||
if (cfg.apiKey) headers.set('X-API-Key', cfg.apiKey);
|
||||
|
||||
// Bun/undici require half-duplex to stream a request body straight through.
|
||||
const init: RequestInit & { duplex?: 'half' } = {
|
||||
method,
|
||||
headers,
|
||||
body: hasBody ? req.body : undefined,
|
||||
redirect: 'manual', // a transparent proxy passes 3xx through rather than following them
|
||||
};
|
||||
if (hasBody) init.duplex = 'half';
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, init);
|
||||
} catch {
|
||||
console.error(`[slskd] ${method} ${url.pathname} -> upstream unreachable`);
|
||||
return new Response('slskd upstream unreachable', { status: 502 });
|
||||
}
|
||||
|
||||
console.log(`[slskd] ${method} ${url.pathname} -> ${upstream.status} ${Date.now() - started}ms`);
|
||||
return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
|
||||
},
|
||||
});
|
||||
|
||||
// No upstream in the banner: where slskd lives is now per-owner state read from the database per request,
|
||||
// not a constant this process knows at boot.
|
||||
console.log(`[slskd] reverse-proxy listening on 127.0.0.1:${port}`);
|
||||
|
||||
// A background share-tree fetch dies with this process, so anything left 'pending' from the previous life
|
||||
// would spin in the UI forever. Clear it once, at boot, before serving.
|
||||
resetStaleSoulseekBrowses()
|
||||
.then((n) => n && console.log(`[slskd] failed ${n} browse snapshot(s) interrupted by restart`))
|
||||
.catch((err) => console.error('[slskd] could not reset stale browse snapshots', err));
|
||||
|
||||
// ── Command handlers ──
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect to API server ──
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'slskd',
|
||||
handles: ['slskd'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
onConnected() {
|
||||
// Tell the API where our proxy is listening, so it can forward /api/slskd/* here.
|
||||
connection.send({ type: 'slskd:server', port });
|
||||
console.log(`[slskd] reported proxy port ${port} to API`);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Graceful shutdown ──
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[slskd] ${signal} received, shutting down...`);
|
||||
try {
|
||||
server.stop(true);
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -1,195 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { getServiceCredentials } from 'officerdb';
|
||||
|
||||
// slskd upstream config + header hygiene for the officer-slskd sidecar.
|
||||
//
|
||||
// All knowledge of the slskd instance — its URL and its API key — lives here. The platform API is a thin
|
||||
// auth+forward proxy and holds NO slskd credentials.
|
||||
//
|
||||
// The daemon is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `service_connections` (see
|
||||
// databases/officer_db/src/service-connections/queries.ts). It is deliberately no longer read from the
|
||||
// environment: Bun auto-loads `.env` into every process started in the platform directory, so an
|
||||
// `SLSKD_API_KEY` there was also sitting in `officer`'s own process.env — a credential that drives the whole
|
||||
// Soulseek daemon, held by the one process with no code to use it. Nothing in this file reads process.env.
|
||||
|
||||
/** Everything needed to reach the daemon. A candidate being validated has this and nothing else yet. */
|
||||
export type SlskdConfig = { base: string; apiKey: string | null };
|
||||
|
||||
/** Trailing slashes off, so `${base}/api/v0/...` never doubles the separator. */
|
||||
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
|
||||
|
||||
// The transfers view polls every couple of seconds and every request needs the key, so the row is cached
|
||||
// rather than re-read per request. Writes invalidate immediately; the TTL only covers someone editing the
|
||||
// row in psql, which then takes effect within a minute instead of needing a restart.
|
||||
const TTL_MS = 60_000;
|
||||
const cache = new Map<number, { cfg: SlskdConfig | null; at: number }>();
|
||||
|
||||
/**
|
||||
* The owner's slskd connection, or null when nothing is configured — the sidecar then answers 503, and the
|
||||
* UI turns that into the setup form rather than a wall of empty panels.
|
||||
*/
|
||||
export async function getSlskdConfig(userId: number): Promise<SlskdConfig | null> {
|
||||
const hit = cache.get(userId);
|
||||
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
|
||||
|
||||
const creds = await getServiceCredentials(userId, 'slskd');
|
||||
// `url` became nullable when services that inherit one instance landed (see the schema column note).
|
||||
// slskd fronts a single daemon and always stores its own, so null here is a malformed row, not an
|
||||
// inheriting one — treat it as unconfigured rather than reaching for somebody else's URL.
|
||||
const cfg = creds?.url ? { base: normalizeBase(creds.url), apiKey: creds.secret } : null;
|
||||
cache.set(userId, { cfg, at: Date.now() });
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Drop the cached row — called by the config routes after any save or removal. */
|
||||
export function invalidateSlskdConfig(userId: number): void {
|
||||
cache.delete(userId);
|
||||
}
|
||||
|
||||
/** The auth header slskd expects, ready to spread. Empty when the daemon has no key configured. */
|
||||
export const authHeaders = (cfg: SlskdConfig): Record<string, string> =>
|
||||
cfg.apiKey ? { 'X-API-Key': cfg.apiKey } : {};
|
||||
|
||||
// Hop-by-hop headers must not cross a proxy hop (RFC 7230 §6.1). `host` is dropped so the outgoing
|
||||
// fetch sets the upstream authority itself; everything else — including our injected X-API-Key — passes.
|
||||
const HOP_BY_HOP = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
'host',
|
||||
]);
|
||||
|
||||
/** Copy headers, dropping only hop-by-hop / proxy-* ones. Used for both request and response directions. */
|
||||
export function stripHopByHop(src: Headers): Headers {
|
||||
const out = new Headers();
|
||||
src.forEach((value, key) => {
|
||||
const k = key.toLowerCase();
|
||||
if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return;
|
||||
out.set(key, value);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user