The sixth plugin out, immediately after photos. A sidecar, a proxy router and 29 web files; no db/ directory, because there are no tables. ── The one thing that is not self-contained ── The connection lives in `service_connections`, a CORE table, reached through getServiceConnection/saveServiceConnection/getServiceCredentials. This plugin declares no schema. Deliberate: that table is the app store's shared credential store, keyed on (user_id, service), already encrypted at rest and already read by app-store/effects.ts. A private copy of the same three columns would be a second place the same fact could live, and the runbook is explicit that a move plus a data-format change gives any failure two causes. The cost, stated rather than hidden: this plugin depends on a core facility it does not own, and would break if that table changed shape. Photos, by contrast, owns photos_config outright. ── 38 palette classes paid down, mapped by meaning ── blue downloading / download speed -> info cyan checking (verifying) -> busy emerald seeding / complete -> success amber queued / stalled / warnings -> warning Collapsing blue and cyan into one tone would have lost a real distinction in the status dot, so `checking` went to `busy` — in-progress work, which is what that tone is for. ── A near-miss worth recording ── The first copy globbed *.tsx and *.ts and silently missed detail/ and dialogs/ — ten components. tsgo caught it as ten "cannot find module" errors. Worth stating because it is quiet in the other direction: had those been imported lazily, the typecheck would have passed and the panels would have broken at runtime. Check the directory tree, not the file glob. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
7.4 KiB
TypeScript
157 lines
7.4 KiB
TypeScript
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
|
import { handleConfigRoute } from './config';
|
|
import { handleOfficerRoute } from './routes';
|
|
import { probe } from './rpc';
|
|
import { getTransmissionConfig } from './upstream';
|
|
import { API_URL } from '@@/officer-url.mjs';
|
|
|
|
// The officer-transmission sidecar. Owns the whole Transmission contract for Officer: the daemon URL and
|
|
// credentials, the X-Transmission-Session-Id CSRF handshake, and the translation from Transmission's
|
|
// single-endpoint RPC vocabulary into real REST paths. The platform API is a thin auth-gated forwarder
|
|
// (src/servers/api/transmission/router.ts) holding no Transmission credentials.
|
|
//
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
// HTTP CONTRACT — the platform strips its /api/transmission mount prefix before forwarding.
|
|
//
|
|
// GET /_health ours. Probes the daemon with a cheap session-get.
|
|
// GET /_config { configured, connection } — where the daemon is, never the password
|
|
// PUT /_config { url, username?, password?, rpcPath? } — validated with a live
|
|
// session-get, then stored. A blank password keeps the stored one
|
|
// DEL /_config forget the connection
|
|
// GET /_officer/session full session settings + version + rpc-version
|
|
// POST /_officer/session write a partial settings object (whitelisted), returns it read back
|
|
// GET /_officer/stats session-stats: cumulative + current-session counters
|
|
// GET /_officer/torrents the list poll — LIST_FIELDS for every torrent, decorated
|
|
// GET /_officer/torrents/:id one torrent with files/peers/trackers (DETAIL_FIELDS)
|
|
// POST /_officer/torrents/add {metainfo|filename, downloadDir?, labels?, paused?, …}
|
|
// → {status:'added'|'duplicate', torrent}
|
|
// POST /_officer/torrents/action {ids, action} — start|start-now|stop|verify|reannounce|queue-*
|
|
// POST /_officer/torrents/set {ids, …whitelisted torrent-set fields}
|
|
// POST /_officer/torrents/location {ids, location, move}
|
|
// POST /_officer/torrents/rename {id, path, name}
|
|
// POST /_officer/torrents/remove {ids, deleteLocalData}
|
|
// GET /_officer/free-space?path= free space at a path
|
|
// GET /_officer/port-test is the peer port reachable from outside
|
|
// POST /_officer/blocklist-update refresh the blocklist, returns the new size
|
|
// anything else 404
|
|
//
|
|
// There is deliberately NO transparent /transmission/rpc passthrough. Transmission multiplexes every verb
|
|
// through one POST body, reports failures as `{"result": "<error>"}` inside a 200, and demands a CSRF token
|
|
// that rotates on every daemon restart. Proxying that raw would push all three into the browser. Every
|
|
// quirk is absorbed here — see rpc.ts.
|
|
//
|
|
// 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 which daemon to talk to is per-owner data.
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** 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',
|
|
// A base64 .torrent for a large multi-file release travels in the request body of /torrents/add.
|
|
maxRequestBodySize: 256 * 1024 * 1024,
|
|
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(`[transmission] ${req.method} ${url.pathname} failed`, err);
|
|
return Response.json({ error: 'internal error' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
|
|
// configured-but-unreachable daemon is the whole reason the flag is on the response.
|
|
if (url.pathname === '/_health') {
|
|
const cfg = await getTransmissionConfig(userId);
|
|
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
|
|
const started = Date.now();
|
|
const result = await probe(cfg);
|
|
return Response.json(
|
|
{ ...result, configured: true, ms: Date.now() - started },
|
|
{ status: result.ok ? 200 : 502 },
|
|
);
|
|
}
|
|
|
|
if (url.pathname.startsWith('/_officer/')) {
|
|
try {
|
|
const res = await handleOfficerRoute(userId, req, url);
|
|
if (res) return res;
|
|
return Response.json({ error: 'not found' }, { status: 404 });
|
|
} catch (err) {
|
|
console.error(`[transmission] ${req.method} ${url.pathname} failed`, err);
|
|
return Response.json({ error: 'internal error' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
return Response.json({ error: 'not found' }, { status: 404 });
|
|
},
|
|
});
|
|
|
|
// No upstream in the banner: where the daemon lives is now per-owner state read from the database per
|
|
// request, not a constant this process knows at boot.
|
|
console.log(`[transmission] listening on 127.0.0.1:${port}`);
|
|
|
|
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}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const connection = createSidecarConnector({
|
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
|
name: 'transmission',
|
|
handles: ['transmission'],
|
|
onCommand(cmd, reply) {
|
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
|
},
|
|
onConnected() {
|
|
connection.send({ type: 'transmission:server', port });
|
|
console.log(`[transmission] reported server port ${port} to API`);
|
|
},
|
|
});
|
|
|
|
function shutdown(signal: string) {
|
|
console.log(`[transmission] ${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'));
|