commit 864dd16355fa3c9f9c17e659943b3a6856fa3510 Author: Andre Padez Date: Tue Aug 18 19:19:30 2026 +0000 transmission, extracted from the platform into its own repository 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/PLUGIN.md b/PLUGIN.md new file mode 100644 index 0000000..096d9a5 --- /dev/null +++ b/PLUGIN.md @@ -0,0 +1,71 @@ +# Transmission — the extraction record + +Extracted from the platform on **2026-08-18**, the sixth plugin out, immediately after photos. +`plugins/EXTRACTING-A-PLUGIN.md` is the runbook. + +## What moved + +| Was | Is now | +| ------------------------------------------------ | --------------- | +| `src/servers/sidecar/transmission/*` (6 files) | `sidecar/*` | +| `src/servers/api/transmission/router.ts` | `api/router.ts` | +| `src/workspaces/officerdev/src/apps/Transmission/*` (28 files, incl. `detail/` and `dialogs/`) | `web/*` | +| `src/apps/officer-web/Screens/Dashboard/Transmission/*` | `web/layout.ts` | + +No `db/` directory, because there are no tables — see below. + +## The one thing that is not self-contained + +**The connection lives in `service_connections`, a CORE table**, reached through `getServiceConnection` / +`saveServiceConnection` / `getServiceCredentials` from `officerdb`. This plugin declares no schema. + +That is deliberate rather than an oversight. The 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`. Giving +transmission a private copy of the same three columns would create a second place the same fact could +live, and the extraction runbook is explicit that a move plus a data-format change gives any failure two +possible causes. + +The cost, stated plainly: **this plugin depends on a core facility it does not own.** If +`service_connections` ever changes shape, this breaks with it. That is a real coupling and the only part +of this extraction that is not self-contained — recorded here rather than discovered later. Photos, by +contrast, owns `photos_config` outright. + +## Paid down on the way + +**38 palette classes**, which the runbook is explicit about: a plugin using Tailwind palette numbers stops +following the theme, and an extraction that leaves them exports colour debt into a repository nobody +revisits. + +They were mapped by MEANING rather than by hue, onto the closed tone set in `components/Data/tone.ts`: + +| was | meant | is now | +| -------- | ------------------------------ | --------- | +| 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, which is why +`checking` went to `busy` — it is in-progress work, which is exactly what that tone is for. + +**Test fixtures.** Transmission was the canonical `members: 'none'` fixture (a single-tenant daemon with no +user concept) across three app-store test files. Repointed at `slskd`, which has the identical shape, +rather than deleted — the assertions are about the installer, not about transmission. + +## A near-miss worth recording + +The first copy took `*.tsx` and `*.ts` from the app directory and **silently missed `detail/` and +`dialogs/`** — ten components. Nothing said so; `tsgo` caught it as ten "cannot find module" errors +pointing at files that had simply not been copied. + +Worth stating because the failure is quiet in the other direction too: had those subdirectories contained +only components imported lazily, the typecheck would have passed and the panels would have broken at +runtime. **Check the directory tree, not the file glob**, when moving a frontend. + +## Still open + +- **The daemon is still provisioned by the app store**, not by this plugin. `modes: ['existing', + 'provisioned']` and a compose template lived in the catalogue entry that had to be deleted, so the + `provisioned` half has no home until Docker provisioning for plugins exists. Point it at a daemon you + already run. +- **No `assets/icon.png`.** Falls back to the `Download` glyph. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e40d55c --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Transmission + +Your torrent daemon, driven from Officer. + +Transmission does not install or supervise the daemon and holds no torrents of its own. You point it at a +daemon's RPC endpoint; it speaks that protocol for you and keeps the credentials server-side, so no +device ever carries them. + +## What you get + +- **The queue**, virtualised — thousands of torrents scroll without the list breaking a sweat. +- **Per-torrent detail**: general, files (with per-file priority and wanted/unwanted), peers, trackers. +- **The usual verbs** — add by magnet or file, remove with or without data, move, relabel, set options. +- **Stats and speed limits**, including the alternative-speed schedule. + +## What you need + +**A Transmission daemon you already run**, with RPC enabled — its URL, the RPC path (usually +`/transmission/rpc`), and a username and password if it requires them. + +Nothing else. No system packages, no host binaries. + +## Setup + +1. Install the plugin. +2. Open **/transmission → Connection** and enter the daemon's address and credentials. +3. It probes the RPC endpoint and stores the connection encrypted, server-side. + +## Worth knowing + +- **The connection is stored in a core table, not this plugin's.** `service_connections` is the app + store's shared credential store, keyed by account and service. This plugin ships no schema of its own — + see PLUGIN.md for why that is deliberate and what it costs. +- **It polls.** A torrent client's whole value is live numbers, so the panels refresh on a timer while + open. That is a real difference from, say, a bookkeeping app, and it is intentional. +- **It is a client, not a supervisor.** If the daemon stops, this reports that it cannot reach it. It + will not start it for you. diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..d19cffd --- /dev/null +++ b/api/router.ts @@ -0,0 +1,16 @@ +import { createSidecarProxy } from '@@/sidecar/create-proxy'; +import { mountPrefix } from '@@/plugins/manifest'; +import { manifest } from '../manifest'; + +// /api/transmission/* — auth, then forward to officer-transmission. No routes of its own and no +// Transmission knowledge here: the sidecar owns the RPC contract and holds the daemon's credentials. +// +// The prefix is DERIVED rather than written, for the reason recorded in `plugins/music/api/router.ts`: +// the proxy strips exactly `prefix.length` characters, so a literal is correct only for a first-party +// publisher and silently forwards the wrong path for anyone else. +const proxy = createSidecarProxy({ + name: 'transmission', + prefix: `/api${mountPrefix({ appName: 'transmission', manifest })}`, +}); + +export const router = proxy.router; diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..75122ee --- /dev/null +++ b/manifest.ts @@ -0,0 +1,43 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +/** + * Transmission — the torrent daemon you already run, driven from Officer. + * + * ── What this plugin is, and what it is not ── + * + * It does not install or supervise Transmission, and it holds no torrents. You point it at a daemon's + * RPC endpoint and it speaks that protocol on your behalf, holding the credentials server-side so no + * device carries them. + * + * ── Where the connection lives ── + * + * In `service_connections`, a CORE table — this plugin declares no schema of its own. That is deliberate + * rather than an omission: the table is the app store's shared credential store, keyed by + * `(user_id, service)`, already encrypted at rest and already read by `app-store/effects.ts`. Giving + * transmission a private copy of the same three columns would be a second place the same fact could live. + * + * It does mean this plugin depends on a core facility it does not own. Recorded in PLUGIN.md rather than + * hidden, because it is the one thing about this extraction that is not self-contained. + * + * ── No `osDependencies` ── + * + * Nothing shells out. The sidecar speaks Transmission's JSON-RPC over `fetch`. + */ +export const manifest: PluginManifest = { + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + + label: 'Transmission', + summary: 'Torrent downloads — queue, files, peers and speed, from your own daemon', + icon: 'Download', + color: '#dc2626', + + permissions: [ + { + key: 'transmission', + label: 'Transmission', + description: 'Torrent downloads', + }, + ], +}; diff --git a/sidecar/config.ts b/sidecar/config.ts new file mode 100644 index 0000000..e3ab8b1 --- /dev/null +++ b/sidecar/config.ts @@ -0,0 +1,91 @@ +import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb'; +import { probe } from './rpc'; +import { + basicAuth, + getTransmissionConfig, + invalidateTransmissionConfig, + normalizeBase, + normalizeRpcPath, +} from './upstream'; + +// `/_config` — where the Transmission daemon is, driven from the app rather than from a shell on the server. +// +// ONE connection, not a registry: nobody runs two Transmission 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. +// +// The URL is the only thing usually needed. Most Transmission daemons run with no RPC auth at all, so +// username/password are optional and an EMPTY username means "no auth" rather than "empty credentials" — +// see basicAuth in upstream.ts, where sending a Basic header anyway makes the daemon reject the request. +// `rpcPath` is there for the reverse-proxy case and defaults to /transmission/rpc. +// +// The password is WRITE-ONLY across this boundary: the GET reports whether one is stored, never its value. +// A save is validated with a live session-get first, so a wrong URL or rejected credentials is a 400 with +// the reason rather than a stored row that makes every later screen fail mysteriously. + +/** What the browser is allowed to know about the connection. Never includes the password. */ +async function connectionState(userId: number): Promise { + const connection = await getServiceConnection(userId, 'transmission'); + return Response.json({ configured: !!connection, connection }); +} + +const bad = (error: string, status = 400) => Response.json({ error }, { status }); + +type ConnectionBody = { url?: unknown; username?: unknown; password?: unknown; rpcPath?: unknown }; + +const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url); + +const readString = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); + +/** Save the connection: validated against the live daemon, then stored. */ +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) : ''; + const username = readString(body.username); + const rpcPath = normalizeRpcPath(readString(body.rpcPath)); + // A blank password with a username still set means "keep the stored one", so changing the URL does not + // need the password re-typed. Clearing the username clears the auth entirely. + const password = typeof body.password === 'string' && body.password ? body.password : null; + + if (!url) return bad('url is required'); + if (!isHttpUrl(url)) return bad('url must start with http:// or https://'); + + const current = await getServiceConnection(userId, 'transmission'); + const keepPassword = !password && !!username && username === current?.username && current.hasSecret; + + // Probing needs the real password, so a save that keeps the stored one borrows it through the normal read + // path rather than reaching for the ciphertext here. + const stored = keepPassword ? await getTransmissionConfig(userId) : null; + const auth = keepPassword ? (stored?.auth ?? null) : basicAuth(username || null, password); + + const result = await probe({ base: url, rpcPath, auth }); + if (!result.ok) return bad(result.error ?? 'could not reach the daemon'); + + const connection = await saveServiceConnection({ + userId, + service: 'transmission', + url, + username: username || null, + // undefined keeps the stored password; null clears it, which is what dropping the username means. + secret: keepPassword ? undefined : (password ?? null), + path: rpcPath, + version: result.version ?? null, + }); + invalidateTransmissionConfig(userId); + return Response.json({ connection, rpcVersion: result.rpcVersion ?? null }); +} + +/** `/_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, 'transmission'); + invalidateTransmissionConfig(userId); + return connectionState(userId); + } + + return bad('method not allowed', 405); +} diff --git a/sidecar/index.ts b/sidecar/index.ts new file mode 100644 index 0000000..a7346f4 --- /dev/null +++ b/sidecar/index.ts @@ -0,0 +1,156 @@ +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": ""}` 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).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')); diff --git a/sidecar/routes.ts b/sidecar/routes.ts new file mode 100644 index 0000000..f4ceb0b --- /dev/null +++ b/sidecar/routes.ts @@ -0,0 +1,367 @@ +import type { SessionSettings, SessionStats, Torrent } from './types'; +import { DETAIL_FIELDS, LIST_FIELDS } from './types'; +import { decorate, rpc, TransmissionError } from './rpc'; + +// Officer-owned routes for the transmission sidecar — the entire feature surface lives under /_officer/. +// +// Nothing here is a passthrough. Transmission speaks a single POST endpoint with a `method` field, which is +// a terrible fit for an HTTP cache, for auth scoping and for a browser client: it would mean shipping the +// 409 handshake, the `result: 'success'` convention and the whole method vocabulary into the SPA. So the +// verbs are re-expressed as real REST paths and every quirk is absorbed in rpc.ts. + +export type OfficerContext = { req: Request; url: URL; userId: number }; + +/** 400 with a machine-readable reason. */ +export const badRequest = (error: string) => Response.json({ error }, { status: 400 }); +/** 404 for an unknown /_officer/ path or a missing object. */ +export const notFound = (error = 'not found') => Response.json({ error }, { status: 404 }); +/** 405 when the path exists but the verb doesn't. */ +export const methodNotAllowed = () => Response.json({ error: 'method not allowed' }, { status: 405 }); + +async function readJson(req: Request): Promise | null> { + const body = await req.json().catch(() => null); + return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record) : null; +} + +/** Torrent ids as Transmission wants them: a number array. Rejects anything that isn't a positive integer. */ +function readIds(body: Record): number[] | null { + const raw = body['ids']; + if (!Array.isArray(raw)) return null; + const ids = raw.map(Number).filter((n) => Number.isInteger(n) && n > 0); + return ids.length === raw.length ? ids : null; +} + +// Actions that take {ids} and nothing else. Mapping them by name keeps the browser from naming RPC methods. +const ACTIONS: Record = { + start: 'torrent-start', + 'start-now': 'torrent-start-now', + stop: 'torrent-stop', + verify: 'torrent-verify', + reannounce: 'torrent-reannounce', + 'queue-top': 'queue-move-top', + 'queue-up': 'queue-move-up', + 'queue-down': 'queue-move-down', + 'queue-bottom': 'queue-move-bottom', +}; + +// Whitelists, not passthroughs. Transmission silently accepts some nonsense and hard-fails on other, so the +// browser is never allowed to name an arbitrary RPC argument — a typo here is a 400, not a silent no-op. +const TORRENT_SET_FIELDS = new Set([ + 'bandwidthPriority', + 'downloadLimit', + 'downloadLimited', + 'files-unwanted', + 'files-wanted', + 'group', + 'honorsSessionLimits', + 'labels', + 'peer-limit', + 'priority-high', + 'priority-low', + 'priority-normal', + 'queuePosition', + 'seedIdleLimit', + 'seedIdleMode', + 'seedRatioLimit', + 'seedRatioMode', + 'sequential_download', + 'trackerAdd', + 'trackerList', + 'uploadLimit', + 'uploadLimited', +]); + +const SESSION_SET_FIELDS = new Set([ + 'alt-speed-down', + 'alt-speed-enabled', + 'alt-speed-time-begin', + 'alt-speed-time-day', + 'alt-speed-time-enabled', + 'alt-speed-time-end', + 'alt-speed-up', + 'blocklist-enabled', + 'blocklist-url', + 'cache-size-mb', + 'default-trackers', + 'dht-enabled', + 'download-dir', + 'download-queue-enabled', + 'download-queue-size', + 'encryption', + 'idle-seeding-limit', + 'idle-seeding-limit-enabled', + 'incomplete-dir', + 'incomplete-dir-enabled', + 'lpd-enabled', + 'peer-limit-global', + 'peer-limit-per-torrent', + 'peer-port', + 'peer-port-random-on-start', + 'pex-enabled', + 'port-forwarding-enabled', + 'queue-stalled-enabled', + 'queue-stalled-minutes', + 'rename-partial-files', + 'script-torrent-done-enabled', + 'script-torrent-done-filename', + 'seed-queue-enabled', + 'seed-queue-size', + 'seedRatioLimit', + 'seedRatioLimited', + 'speed-limit-down', + 'speed-limit-down-enabled', + 'speed-limit-up', + 'speed-limit-up-enabled', + 'start-added-torrents', + 'trash-original-torrent-files', + 'utp-enabled', +]); + +function pick(body: Record, allowed: Set): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (allowed.has(key)) out[key] = value; + } + return out; +} + +/** + * Dispatch an /_officer/* request. Returns null when nothing matches, which the caller turns into a 404. + * + * `userId` was authenticated by the platform proxy and validated at the server boundary — every call below + * needs it, because which daemon to talk to is per-owner state read from the database. + */ +export async function handleOfficerRoute(userId: number, req: Request, url: URL): Promise { + const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean); + if (segments.length === 0) return null; + + const ctx: OfficerContext = { req, url, userId }; + + try { + switch (segments[0]) { + case 'session': + return await handleSession(ctx, segments.slice(1)); + case 'stats': + return await handleStats(ctx); + case 'torrents': + return await handleTorrents(ctx, segments.slice(1)); + case 'free-space': + return await handleFreeSpace(ctx); + case 'port-test': + return await handlePortTest(ctx); + case 'blocklist-update': + return await handleBlocklistUpdate(ctx); + default: + return null; + } + } catch (err) { + if (err instanceof TransmissionError) return Response.json({ error: err.message }, { status: err.status }); + throw err; + } +} + +async function handleSession(ctx: OfficerContext, rest: string[]): Promise { + if (rest.length > 0) return null; + + if (ctx.req.method === 'GET') { + // No `fields` argument: session-get returns everything, and the settings UI reads nearly all of it. + const session = await rpc(ctx.userId, 'session-get'); + return Response.json({ session }); + } + + if (ctx.req.method === 'POST') { + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON object body'); + const args = pick(body, SESSION_SET_FIELDS); + if (Object.keys(args).length === 0) return badRequest('no writable session fields in body'); + await rpc(ctx.userId, 'session-set', args); + // Read back rather than echoing the request: Transmission clamps and normalises several of these + // (alt-speed times, queue sizes), so the request body is not what the daemon ends up holding. + const session = await rpc(ctx.userId, 'session-get'); + return Response.json({ session }); + } + + return methodNotAllowed(); +} + +async function handleStats(ctx: OfficerContext): Promise { + if (ctx.req.method !== 'GET') return methodNotAllowed(); + const stats = await rpc(ctx.userId, 'session-stats'); + return Response.json({ stats }); +} + +async function handleFreeSpace(ctx: OfficerContext): Promise { + if (ctx.req.method !== 'GET') return methodNotAllowed(); + const path = ctx.url.searchParams.get('path'); + if (!path) return badRequest('path query parameter is required'); + const result = await rpc<{ path: string; 'size-bytes': number }>(ctx.userId, 'free-space', { path }); + return Response.json({ path: result.path, bytes: result['size-bytes'] }); +} + +// GET: it asks the daemon whether the peer port is reachable and returns the answer. Nothing changes, +// on this side or Transmission's. Blocklist-update below stays POST because it genuinely refetches and +// replaces the list — the two look alike and are not. +async function handlePortTest(ctx: OfficerContext): Promise { + if (ctx.req.method !== 'GET') return methodNotAllowed(); + const result = await rpc<{ 'port-is-open': boolean }>(ctx.userId, 'port-test'); + return Response.json({ open: result['port-is-open'] }); +} + +async function handleBlocklistUpdate(ctx: OfficerContext): Promise { + if (ctx.req.method !== 'POST') return methodNotAllowed(); + const result = await rpc<{ 'blocklist-size': number }>(ctx.userId, 'blocklist-update'); + return Response.json({ size: result['blocklist-size'] }); +} + +async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise { + const { req } = ctx; + + // GET /_officer/torrents — the list poll. + if (rest.length === 0 && req.method === 'GET') { + const result = await rpc<{ torrents: Torrent[] }>(ctx.userId, 'torrent-get', { fields: [...LIST_FIELDS] }); + return Response.json({ torrents: result.torrents.map(decorate) }); + } + + if (rest.length === 1) { + const [segment] = rest as [string]; + + if (req.method === 'POST') { + switch (segment) { + case 'add': + return await addTorrent(ctx); + case 'action': + return await runAction(ctx); + case 'set': + return await setTorrent(ctx); + case 'location': + return await setLocation(ctx); + case 'rename': + return await renamePath(ctx); + case 'remove': + return await removeTorrents(ctx); + } + } + + // GET /_officer/torrents/:id — the detail poll, one torrent, with the expensive arrays. + const id = Number(segment); + if (!Number.isInteger(id) || id <= 0) return notFound(); + if (req.method !== 'GET') return methodNotAllowed(); + + const result = await rpc<{ torrents: Torrent[] }>(ctx.userId, 'torrent-get', { + ids: [id], + fields: [...LIST_FIELDS, ...DETAIL_FIELDS], + }); + const torrent = result.torrents[0]; + if (!torrent) return notFound('torrent not found'); + return Response.json({ torrent: decorate(torrent) }); + } + + return null; +} + +async function addTorrent(ctx: OfficerContext): Promise { + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON object body'); + + const args: Record = {}; + // Exactly one source. `metainfo` is a base64 .torrent; `filename` is a magnet link or an http(s) URL. + if (typeof body['metainfo'] === 'string' && body['metainfo']) args['metainfo'] = body['metainfo']; + else if (typeof body['filename'] === 'string' && body['filename']) args['filename'] = body['filename']; + else return badRequest('either metainfo or filename is required'); + + if (typeof body['downloadDir'] === 'string' && body['downloadDir']) args['download-dir'] = body['downloadDir']; + if (Array.isArray(body['labels'])) args['labels'] = body['labels'].filter((l) => typeof l === 'string'); + if (typeof body['paused'] === 'boolean') args['paused'] = body['paused']; + if (typeof body['bandwidthPriority'] === 'number') args['bandwidthPriority'] = body['bandwidthPriority']; + if (typeof body['sequentialDownload'] === 'boolean') args['sequential_download'] = body['sequentialDownload']; + + const result = await rpc<{ + 'torrent-added'?: { id: number; name: string; hashString: string }; + 'torrent-duplicate'?: { id: number; name: string; hashString: string }; + }>(ctx.userId, 'torrent-add', args); + + const added = result['torrent-added']; + const duplicate = result['torrent-duplicate']; + // A duplicate is not an error — Transmission reports the torrent you already had, and the UI counts it + // separately so a batch add can say "3 added, 1 already present" instead of failing opaquely. + if (duplicate) return Response.json({ status: 'duplicate', torrent: duplicate }); + if (added) return Response.json({ status: 'added', torrent: added }); + return Response.json({ status: 'added', torrent: null }); +} + +async function runAction(ctx: OfficerContext): Promise { + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON object body'); + + const action = typeof body['action'] === 'string' ? body['action'] : ''; + const method = ACTIONS[action]; + if (!method) return badRequest(`unknown action "${action}"`); + + const ids = readIds(body); + if (!ids) return badRequest('ids must be an array of positive integers'); + if (ids.length === 0) return Response.json({ ok: true, affected: 0 }); + + await rpc(ctx.userId, method, { ids }); + return Response.json({ ok: true, affected: ids.length }); +} + +async function setTorrent(ctx: OfficerContext): Promise { + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON object body'); + + const ids = readIds(body); + if (!ids) return badRequest('ids must be an array of positive integers'); + + const fields = pick(body, TORRENT_SET_FIELDS); + if (Object.keys(fields).length === 0) return badRequest('no writable torrent fields in body'); + if (ids.length === 0) return Response.json({ ok: true, affected: 0 }); + + await rpc(ctx.userId, 'torrent-set', { ids, ...fields }); + return Response.json({ ok: true, affected: ids.length }); +} + +async function setLocation(ctx: OfficerContext): Promise { + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON object body'); + + const ids = readIds(body); + if (!ids) return badRequest('ids must be an array of positive integers'); + const location = typeof body['location'] === 'string' ? body['location'].trim() : ''; + if (!location) return badRequest('location is required'); + if (ids.length === 0) return Response.json({ ok: true, affected: 0 }); + + // `move: false` re-points the torrent at data already sitting there; `true` physically moves it. Getting + // this backwards either loses the data or copies gigabytes unasked, so it is required, not defaulted. + const move = body['move'] === true; + await rpc(ctx.userId, 'torrent-set-location', { ids, location, move }); + return Response.json({ ok: true, affected: ids.length }); +} + +async function renamePath(ctx: OfficerContext): Promise { + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON object body'); + + const id = Number(body['id']); + if (!Number.isInteger(id) || id <= 0) return badRequest('id must be a positive integer'); + const path = typeof body['path'] === 'string' ? body['path'] : ''; + const name = typeof body['name'] === 'string' ? body['name'].trim() : ''; + if (!path) return badRequest('path is required'); + if (!name) return badRequest('name is required'); + + // torrent-rename-path takes ONE id — an array is accepted but the result is undefined. Enforce it. + await rpc(ctx.userId, 'torrent-rename-path', { ids: [id], path, name }); + return Response.json({ ok: true }); +} + +async function removeTorrents(ctx: OfficerContext): Promise { + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON object body'); + + const ids = readIds(body); + if (!ids) return badRequest('ids must be an array of positive integers'); + if (ids.length === 0) return Response.json({ ok: true, affected: 0 }); + + const deleteLocalData = body['deleteLocalData'] === true; + await rpc(ctx.userId, 'torrent-remove', { ids, 'delete-local-data': deleteLocalData }); + return Response.json({ ok: true, affected: ids.length, deletedData: deleteLocalData }); +} diff --git a/sidecar/rpc.ts b/sidecar/rpc.ts new file mode 100644 index 0000000..477569b --- /dev/null +++ b/sidecar/rpc.ts @@ -0,0 +1,165 @@ +import type { Torrent, TrackerStat } from './types'; +import type { TransmissionConfig } from './upstream'; +import { getTransmissionConfig } from './upstream'; + +// The Transmission RPC call layer. Every upstream request in this sidecar goes through here, so the +// wire-level quirks are handled exactly once: +// +// • THE 409 HANDSHAKE. Transmission's CSRF defence answers the first request of a session with +// `409 Conflict` and an `X-Transmission-Session-Id` header, expecting the client to repeat the request +// carrying it. The token also rotates whenever the daemon restarts, so this is not a start-up ritual you +// can do once — any call can 409 at any time. Absorbing it here is the single biggest reason this +// sidecar exists: a client that handles it naively works until the daemon is restarted and then fails +// in a way that looks like an auth bug. +// • Errors are NOT signalled by HTTP status. A perfectly successful-looking 200 carries +// `{"result": "some error string"}`; only `result === 'success'` means it worked. +// • Auth is HTTP Basic, and an EMPTY username must send no header at all — see basicAuth in upstream.ts. +// • `torrent-get` with an unknown field name fails the whole call rather than ignoring the field, so +// LIST_FIELDS/DETAIL_FIELDS are curated against the running daemon's rpc-version, not guessed. + +const DEFAULT_TIMEOUT_MS = 30_000; + +/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */ +export class TransmissionError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = 'TransmissionError'; + } +} + +// The CSRF token for a daemon session, keyed by endpoint rather than by owner: the token belongs to the +// daemon, so two owners pointed at the same one share a refresh, and re-pointing at a different daemon +// cannot carry a token that daemon never issued. +const sessionIds = new Map(); + +type RpcResponse = { result: string; arguments?: T }; + +/** Issue one RPC call against the owner's configured daemon. */ +export async function rpc(userId: number, method: string, args: Record = {}): Promise { + const cfg = await getTransmissionConfig(userId); + if (!cfg) throw new TransmissionError(503, 'transmission is not connected'); + return call(cfg, method, args, true); +} + +/** The same call against a connection that may not be stored yet — used to validate one before saving it. */ +export async function rpcWith( + cfg: TransmissionConfig, + method: string, + args: Record = {}, +): Promise { + return call(cfg, method, args, true); +} + +async function call( + cfg: TransmissionConfig, + method: string, + args: Record, + mayRetry: boolean, +): Promise { + const endpoint = `${cfg.base}${cfg.rpcPath}`; + + const headers: Record = { 'Content-Type': 'application/json' }; + const sessionId = sessionIds.get(endpoint); + if (sessionId) headers['X-Transmission-Session-Id'] = sessionId; + if (cfg.auth) headers['Authorization'] = cfg.auth; + + let res: Response; + try { + res = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify({ method, arguments: args }), + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + }); + } catch (err) { + const reason = err instanceof Error && err.name === 'TimeoutError' ? 'timed out' : 'unreachable'; + throw new TransmissionError(502, `transmission upstream ${reason}`); + } + + if (res.status === 409) { + const fresh = res.headers.get('X-Transmission-Session-Id'); + if (fresh && mayRetry) { + sessionIds.set(endpoint, fresh); + return call(cfg, method, args, false); + } + throw new TransmissionError(502, 'transmission rejected the session id handshake'); + } + + if (res.status === 401) throw new TransmissionError(401, 'transmission rejected the credentials'); + if (!res.ok) throw new TransmissionError(502, `transmission returned ${res.status}`); + + const body = (await res.json().catch(() => null)) as RpcResponse | null; + if (!body) throw new TransmissionError(502, 'transmission returned an unreadable body'); + // A 200 with a non-'success' result is the normal way Transmission reports a failure. + if (body.result !== 'success') throw new TransmissionError(502, body.result || 'transmission reported a failure'); + + return (body.arguments ?? {}) as T; +} + +/** Reachability + credential probe, used by /_health and before a connection is saved. */ +export async function probe( + cfg: TransmissionConfig, +): Promise<{ ok: boolean; version?: string; rpcVersion?: number; error?: string }> { + try { + const args = await rpcWith<{ version: string; 'rpc-version': number }>(cfg, 'session-get', { + fields: ['version', 'rpc-version'], + }); + return { ok: true, version: args.version, rpcVersion: args['rpc-version'] }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +// ── Derived fields ──────────────────────────────────────────────────────────────────────────────── + +// Subdomain prefixes that carry no identity — `tracker.example.org` and `t.example.org` are the same +// operator to a human grouping their torrents, and showing them as two entries makes the filter useless. +const IGNORED_TRACKER_PREFIXES = new Set(['t', 'tr', 'tk', 'tracker', 'bt', 'open', 'opentracker', 'pt']); + +const ANNOUNCE_STATE: Record = { + 0: 'Inactive', + 1: 'Waiting', + 2: 'Queued', + 3: 'Announcing', +}; + +/** `udp://tr.example.org:1337/announce` → `example.org`. Empty string when there is no tracker at all. */ +export function trackerHost(stats: TrackerStat[]): string { + const primary = stats.find((t) => !t.isBackup) ?? stats[0]; + if (!primary) return ''; + + const raw = primary.host || primary.announce; + let host: string; + try { + host = new URL(raw.includes('://') ? raw : `http://${raw}`).hostname; + } catch { + host = raw + .replace(/^[a-z]+:\/\//i, '') + .split('/')[0]! + .split(':')[0]!; + } + + const parts = host.split('.'); + // Only strip a prefix when something recognisable remains — `t.co` must not become `co`. + if (parts.length > 2 && IGNORED_TRACKER_PREFIXES.has(parts[0]!.toLowerCase())) parts.shift(); + return parts.join('.'); +} + +function trackerStatus(stats: TrackerStat[]): string { + const primary = stats.find((t) => !t.isBackup) ?? stats[0]; + if (!primary) return ''; + if (primary.lastAnnounceSucceeded === false && primary.lastAnnounceResult) return primary.lastAnnounceResult; + return ANNOUNCE_STATE[primary.announceState] ?? ''; +} + +/** Attach the `officer*` fields the UI groups and filters by. Mutating the parsed body is fine — it's ours. */ +export function decorate(torrent: Torrent): Torrent { + const stats = torrent.trackerStats ?? []; + torrent.officerTrackerHost = trackerHost(stats); + torrent.officerTrackerStatus = trackerStatus(stats); + torrent.officerTrackerErrors = stats.filter((t) => t.lastAnnounceSucceeded === false && t.hasAnnounced).length; + return torrent; +} diff --git a/sidecar/types.ts b/sidecar/types.ts new file mode 100644 index 0000000..e8e5db1 --- /dev/null +++ b/sidecar/types.ts @@ -0,0 +1,244 @@ +// Wire types for the Transmission RPC surface this sidecar uses. +// +// Field names are Transmission's OWN, verbatim — including its inconsistent casing (`percentDone` next to +// `begin_piece`, `peer-limit`, `sequential_download`). That is deliberate: renaming them would mean every +// future field needs a translation entry, and a name that doesn't appear in Transmission's rpc-spec.md is a +// name you cannot grep for when something misbehaves. The only fields we invent are the `officer*` derived +// ones at the bottom of Torrent, which are prefixed so they can never be mistaken for upstream data. + +/** Transmission's torrent status enum (rpc-spec.md §3.3). */ +export const TorrentStatus = { + Stopped: 0, + CheckWait: 1, + Check: 2, + DownloadWait: 3, + Download: 4, + SeedWait: 5, + Seed: 6, +} as const; + +export type TrackerStat = { + announce: string; + announceState: number; + downloadCount: number; + hasAnnounced: boolean; + hasScraped: boolean; + host: string; + id: number; + isBackup: boolean; + lastAnnouncePeerCount: number; + lastAnnounceResult: string; + lastAnnounceSucceeded: boolean; + lastAnnounceTime: number; + lastScrapeResult: string; + lastScrapeSucceeded: boolean; + lastScrapeTime: number; + leecherCount: number; + nextAnnounceTime: number; + scrape: string; + seederCount: number; + tier: number; +}; + +export type TorrentFile = { + bytesCompleted: number; + length: number; + name: string; +}; + +export type TorrentFileStat = { + bytesCompleted: number; + priority: number; + wanted: boolean; +}; + +export type TorrentPeer = { + address: string; + clientName: string; + clientIsChoked: boolean; + clientIsInterested: boolean; + flagStr: string; + isDownloadingFrom: boolean; + isEncrypted: boolean; + isIncoming: boolean; + isUploadingTo: boolean; + isUTP: boolean; + peerIsChoked: boolean; + peerIsInterested: boolean; + port: number; + progress: number; + rateToClient: number; + rateToPeer: number; +}; + +export type PeersFrom = { + fromCache: number; + fromDht: number; + fromIncoming: number; + fromLpd: number; + fromLtep: number; + fromPex: number; + fromTracker: number; +}; + +export type Torrent = { + id: number; + name: string; + status: number; + totalSize: number; + sizeWhenDone: number; + leftUntilDone: number; + haveValid: number; + percentDone: number; + metadataPercentComplete: number; + recheckProgress?: number; + downloadedEver: number; + uploadedEver: number; + corruptEver?: number; + uploadRatio: number; + rateDownload: number; + rateUpload: number; + eta: number; + peersSendingToUs: number; + peersGettingFromUs: number; + addedDate: number; + doneDate: number; + activityDate: number; + secondsSeeding: number; + downloadDir: string; + bandwidthPriority: number; + queuePosition: number; + isPrivate: boolean; + labels: string[]; + error: number; + errorString: string; + pieceCount: number; + pieceSize: number; + magnetLink: string; + group: string; + 'file-count': number; + trackerStats: TrackerStat[]; + trackerList?: string; + // Per-torrent limits — the fields the "Other settings" dialog reads and writes. + honorsSessionLimits: boolean; + downloadLimited: boolean; + downloadLimit: number; + uploadLimited: boolean; + uploadLimit: number; + 'peer-limit': number; + seedRatioMode: number; + seedRatioLimit: number; + seedIdleMode: number; + seedIdleLimit: number; + sequential_download?: boolean; + // Detail-only fields — present when fetched through /_officer/torrents/:id. + hashString?: string; + comment?: string; + creator?: string; + dateCreated?: number; + maxConnectedPeers?: number; + files?: TorrentFile[]; + fileStats?: TorrentFileStat[]; + peers?: TorrentPeer[]; + peersFrom?: PeersFrom; + // ── Derived by the sidecar, never sent by Transmission ── + /** + * The primary tracker's host with its port and any ignorable subdomain prefix stripped + * (`udp://tr.example.org:1337/announce` → `example.org`). The UI groups by this, so the normalisation + * has to be one implementation, not one per caller. + */ + officerTrackerHost: string; + /** Human-readable announce state of the primary tracker, for the "Tracker Status" column. */ + officerTrackerStatus: string; + /** Count of trackers that have reported an error, so the UI can flag a torrent without walking the array. */ + officerTrackerErrors: number; +}; + +export type SessionSettings = Record & { + version: string; + 'rpc-version': number; + 'download-dir': string; +}; + +export type SessionStats = { + activeTorrentCount: number; + pausedTorrentCount: number; + torrentCount: number; + downloadSpeed: number; + uploadSpeed: number; + 'cumulative-stats': StatsBucket; + 'current-stats': StatsBucket; +}; + +export type StatsBucket = { + downloadedBytes: number; + uploadedBytes: number; + filesAdded: number; + sessionCount: number; + secondsActive: number; +}; + +/** The fields the torrent list needs. Mirrors what the list UI actually renders — nothing speculative. */ +export const LIST_FIELDS = [ + 'activityDate', + 'addedDate', + 'bandwidthPriority', + 'corruptEver', + 'doneDate', + 'downloadDir', + 'downloadedEver', + 'downloadLimit', + 'downloadLimited', + 'error', + 'errorString', + 'eta', + 'file-count', + 'group', + 'haveValid', + 'honorsSessionLimits', + 'id', + 'isPrivate', + 'labels', + 'leftUntilDone', + 'magnetLink', + 'metadataPercentComplete', + 'name', + 'peer-limit', + 'peersGettingFromUs', + 'peersSendingToUs', + 'percentDone', + 'pieceCount', + 'pieceSize', + 'queuePosition', + 'rateDownload', + 'rateUpload', + 'secondsSeeding', + 'seedIdleLimit', + 'seedIdleMode', + 'seedRatioLimit', + 'seedRatioMode', + 'sequential_download', + 'sizeWhenDone', + 'status', + 'totalSize', + 'trackerList', + 'trackerStats', + 'uploadedEver', + 'uploadLimit', + 'uploadLimited', + 'uploadRatio', +] as const; + +/** Extra fields only the detail pane needs — big arrays we refuse to pull for every row on every poll. */ +export const DETAIL_FIELDS = [ + 'comment', + 'creator', + 'dateCreated', + 'fileStats', + 'files', + 'hashString', + 'maxConnectedPeers', + 'peers', + 'peersFrom', + 'recheckProgress', +] as const; diff --git a/sidecar/upstream.ts b/sidecar/upstream.ts new file mode 100644 index 0000000..7a0ed8c --- /dev/null +++ b/sidecar/upstream.ts @@ -0,0 +1,74 @@ +import { getServiceCredentials } from 'officerdb'; + +// Transmission upstream config for the officer-transmission sidecar. +// +// All knowledge of the daemon — its URL, its RPC path and its credentials — lives here. The platform API is +// a thin auth+forward proxy and holds NO Transmission credentials. +// +// The daemon is CONFIGURED BY THE OWNER FROM THE UI and stored in `service_connections` (see +// databases/officer_db/src/service-connections/queries.ts), no longer read from the environment: Bun +// auto-loads `.env` into every process started in the platform directory, so TRANSMISSION_* was also +// sitting in `officer`'s own process.env, and pointing Officer at a daemon meant editing a file on the +// server. Nothing in this file reads process.env. + +/** Everything needed to make one RPC call. A candidate being validated has this and nothing else yet. */ +export type TransmissionConfig = { base: string; rpcPath: string; auth: string | null }; + +/** Trailing slashes off, so `${base}${rpcPath}` never doubles the separator. */ +export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, ''); + +/** + * The RPC endpoint path. Transmission serves it at /transmission/rpc by default, but a reverse proxy can + * mount it anywhere, so it is stored per connection rather than hardcoded. + */ +export const normalizeRpcPath = (raw: string | null | undefined): string => { + const path = raw?.trim(); + if (!path) return '/transmission/rpc'; + return path.startsWith('/') ? path : `/${path}`; +}; + +/** + * The `Authorization: Basic …` header value, or null when the daemon has no auth configured. + * + * Transmission treats an empty username as "no authentication required" — sending an empty Basic header in + * that case is not merely useless, it makes the daemon reject the request. So this returns null unless a + * username is actually set, which is why the stored username is nullable rather than defaulting to ''. + */ +export function basicAuth(username: string | null, password: string | null): string | null { + const user = username?.trim(); + if (!user) return null; + return `Basic ${Buffer.from(`${user}:${password ?? ''}`).toString('base64')}`; +} + +// The torrent list polls every couple of seconds, 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(); + +/** + * The owner's Transmission connection, or null when nothing is configured — the sidecar then answers 503, + * and the UI turns that into the setup form rather than an empty torrent list. + */ +export async function getTransmissionConfig(userId: number): Promise { + const hit = cache.get(userId); + if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg; + + const creds = await getServiceCredentials(userId, 'transmission'); + // `url` became nullable when services that inherit one instance landed (see the schema column note). + // Transmission fronts a single daemon and always stores its own, so null here is a malformed row. + const cfg = creds?.url + ? { + base: normalizeBase(creds.url), + rpcPath: normalizeRpcPath(creds.path), + auth: basicAuth(creds.username, 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 invalidateTransmissionConfig(userId: number): void { + cache.delete(userId); +} diff --git a/web/ConnectionView.tsx b/web/ConnectionView.tsx new file mode 100644 index 0000000..c7d2bcd --- /dev/null +++ b/web/ConnectionView.tsx @@ -0,0 +1,219 @@ +import type { ServiceConnection } from 'officerdev'; +import { useEffect, useState } from 'react'; +import { CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + serviceErrorMessage, + useServiceConnection, + useServiceConnectionActions, + useServiceHealth, +} from 'officerdev'; + +// Connecting a Transmission daemon to Officer, from the app. +// +// This screen is BOTH the setup wizard and the permanent settings page: TransmissionView renders it in place +// of whatever section the URL asks for while nothing is connected, and /transmission/connection renders it +// for good. One component, so re-pointing at a different daemon later goes through exactly the code path +// that stored the first one. +// +// ONE connection, not a registry — nobody runs two Transmission daemons. And usually one FIELD: most +// daemons run with no RPC auth at all, which is why username/password sit behind a disclosure rather than +// in the owner's way. An empty username means "no auth", not "empty credentials"; the sidecar is careful +// about that distinction because Transmission rejects a request carrying an empty Basic header. + +const HINT = 'text-[11px] leading-relaxed text-muted-foreground'; + +/** Transmission's own default RPC port. Officer dials it from the server, not from this browser. */ +const DEFAULT_URL = 'http://localhost:9091'; + +const URL_HINT = + "The daemon's base URL, without the RPC path. Officer reaches it from the server, not from this browser " + + '— so localhost here means the machine Officer runs on.'; + +const AUTH_HINT = + 'Only if the daemon has rpc-authentication-required set. Leave the username empty for the usual case: an ' + + 'empty username means no authentication, and sending a blank one anyway makes Transmission refuse the call.'; + +const PATH_HINT = 'Only differs behind a reverse proxy that mounts the RPC endpoint somewhere else.'; + +type FieldProps = { + label: string; + hint?: string; + value: string; + onChange: (value: string) => void; + placeholder: string; + type?: string; + autoFocus?: boolean; +}; + +const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => ( + +); + +type SaveInput = Record & { url: string }; + +export const ConnectionView = () => { + const { data, isLoading } = useServiceConnection('transmission'); + const { data: health } = useServiceHealth('transmission'); + const { save, forget } = useServiceConnectionActions('transmission'); + + const connection = data?.connection ?? null; + + const [url, setUrl] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [rpcPath, setRpcPath] = useState(''); + const [showAdvanced, setShowAdvanced] = useState(false); + const [error, setError] = useState(''); + + // Seed the form from the stored row once it arrives. Keyed on the row's id so a save (which returns the + // same id) does not stomp what the owner is still typing, but forgetting and re-adding does reset it. + useEffect(() => { + setUrl(connection?.url ?? ''); + setUsername(connection?.username ?? ''); + setRpcPath(connection?.path ?? ''); + setPassword(''); + setShowAdvanced(!!connection?.username); + }, [connection?.id]); + + const submit = async () => { + setError(''); + try { + await save.mutateAsync({ url: url.trim(), username: username.trim(), password, rpcPath: rpcPath.trim() }); + setPassword(''); + } catch (err) { + setError(serviceErrorMessage(err)); + } + }; + + const remove = async () => { + setError(''); + try { + await forget.mutateAsync(); + } catch (err) { + setError(serviceErrorMessage(err)); + } + }; + + if (isLoading) { + return ( +
+ Loading… +
+ ); + } + + return ( +
+
+
+
+ +
+
+

Transmission daemon

+

+ {connection + ? 'Where Officer talks to Transmission. Saving re-checks the daemon before storing anything.' + : 'Point Officer at your Transmission daemon. Usually the URL is all it needs.'} +

+
+
+ + {connection && } + +
+ + + + + {showAdvanced && ( + <> + + + + + )} + + {error && ( +
+ + {error} +
+ )} + +
+ + {connection && ( + + )} +
+
+
+
+ ); +}; + +type StatusRowProps = { connection: ServiceConnection; health: { ok: boolean; error?: string } | undefined }; + +const StatusRow = ({ connection, health }: StatusRowProps) => ( +
+ {health?.ok ? ( + + ) : ( + + )} +
+
{health?.ok ? 'Connected' : 'Not responding'}
+
+ {connection.url} + {connection.version ? ` · Transmission ${connection.version}` : ''} +
+ {!health?.ok && health?.error &&
{health.error}
} +
+
+); diff --git a/web/SettingsView.tsx b/web/SettingsView.tsx new file mode 100644 index 0000000..927579a --- /dev/null +++ b/web/SettingsView.tsx @@ -0,0 +1,396 @@ +import type { ReactNode } from 'react'; +import { useState } from 'react'; +import { Loader2, Plug, ShieldCheck } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { useMaintenance, useSession } from './useTransmissionData'; + +// The daemon's settings, as one form with a draft. +// +// Only the fields you actually changed are sent — session-set is a partial update, and posting the whole +// object back would race the 5-second poll and re-apply stale values you never touched. The sidecar reads +// the settings back after writing because Transmission clamps several of them, so the form snaps to what +// the daemon really holds rather than to what you typed. + +export const SettingsView = () => { + const { session, isLoading, save } = useSession(); + const { portTest, updateBlocklist } = useMaintenance(); + const [draft, setDraft] = useState>({}); + + if (isLoading && !session) { + return ( +
+ + Loading settings… +
+ ); + } + + if (!session) { + return ( +
+ Could not read the session settings. +
+ ); + } + + const get = (key: string, fallback: T): T => + key in draft ? (draft[key] as T) : ((session[key] as T) ?? fallback); + const set = (key: string, value: unknown) => setDraft((prev) => ({ ...prev, [key]: value })); + + const dirty = Object.keys(draft).length > 0; + + const apply = async () => { + await save.mutateAsync(draft); + setDraft({}); + }; + + const bool = (key: string, label: string, hint?: string) => ( + set(key, v)} label={label} hint={hint} /> + ); + + const number = (key: string, label: string, unit?: string, min = 0) => ( + + set(key, Number(ev.target.value))} + className="w-32" + /> + + ); + + const text = (key: string, label: string, placeholder?: string) => ( +
+ + set(key, ev.target.value)} + className="font-mono text-xs" + /> +
+ ); + + return ( +
+ + + Downloads + Bandwidth + Network + Queue + Other + + +
+ +
+ {text('download-dir', 'Download directory', '/downloads')} + {bool('incomplete-dir-enabled', 'Keep incomplete files somewhere else')} + {get('incomplete-dir-enabled', false) && text('incomplete-dir', 'Incomplete directory')} + {bool( + 'rename-partial-files', + 'Append .part to incomplete files', + 'Stops media scanners picking up half-downloaded files.', + )} +
+ +
+ {bool('start-added-torrents', 'Start torrents as soon as they are added')} + {bool('trash-original-torrent-files', 'Delete the .torrent file after adding')} +
+ +
+ {bool('seedRatioLimited', 'Stop seeding at a ratio')} + {get('seedRatioLimited', false) && ( + + set('seedRatioLimit', Number(ev.target.value))} + className="w-32" + /> + + )} + {bool('idle-seeding-limit-enabled', 'Stop seeding when idle')} + {get('idle-seeding-limit-enabled', false) && number('idle-seeding-limit', 'Idle for', 'minutes')} +
+
+ + +
+ {bool('speed-limit-down-enabled', 'Limit download speed')} + {get('speed-limit-down-enabled', false) && number('speed-limit-down', 'Download', 'kB/s')} + {bool('speed-limit-up-enabled', 'Limit upload speed')} + {get('speed-limit-up-enabled', false) && number('speed-limit-up', 'Upload', 'kB/s')} +
+ +
+ {bool('alt-speed-enabled', 'Use the alternative limits now')} + {number('alt-speed-down', 'Download', 'kB/s')} + {number('alt-speed-up', 'Upload', 'kB/s')} + {bool('alt-speed-time-enabled', 'Switch automatically on a schedule')} + {get('alt-speed-time-enabled', false) && ( + <> + set('alt-speed-time-begin', v)} + /> + set('alt-speed-time-end', v)} + /> + set('alt-speed-time-day', v)} /> + + )} +
+
+ + +
+ {number('peer-port', 'Peer port', undefined, 1)} + {bool('peer-port-random-on-start', 'Pick a random port on each start')} + {bool('port-forwarding-enabled', 'Ask the router to forward it (UPnP / NAT-PMP)')} +
+ +
+
+ +
+ {number('peer-limit-global', 'Maximum peers overall', undefined, 1)} + {number('peer-limit-per-torrent', 'Maximum peers per torrent', undefined, 1)} + + + +
+ +
+ {bool('pex-enabled', 'Peer exchange (PEX)')} + {bool('dht-enabled', 'Distributed hash table (DHT)')} + {bool('lpd-enabled', 'Local peer discovery')} + {bool('utp-enabled', 'µTP — yields to other traffic on your connection')} +
+ +
+ {bool('blocklist-enabled', 'Use a blocklist')} + {text('blocklist-url', 'Blocklist URL')} +
+ + + {Number(session['blocklist-size'] ?? 0).toLocaleString()} rules loaded + +
+
+
+ + +
+ {bool('download-queue-enabled', 'Limit how many torrents download at once')} + {get('download-queue-enabled', false) && number('download-queue-size', 'At most', 'torrents', 1)} +
+
+ {bool('seed-queue-enabled', 'Limit how many torrents seed at once')} + {get('seed-queue-enabled', false) && number('seed-queue-size', 'At most', 'torrents', 1)} +
+
+ {bool('queue-stalled-enabled', 'Treat torrents with no activity as stalled')} + {get('queue-stalled-enabled', false) && number('queue-stalled-minutes', 'After', 'minutes', 1)} +
+
+ + +
{number('cache-size-mb', 'Disk cache', 'MB')}
+ +
+ {bool('script-torrent-done-enabled', 'Run a script when a torrent finishes')} + {get('script-torrent-done-enabled', false) && + text('script-torrent-done-filename', 'Script path', '/etc/transmission/done.sh')} +
+ +
+