diff --git a/TODO.md b/TODO.md index 783444ad..b3806bf3 100644 --- a/TODO.md +++ b/TODO.md @@ -72,6 +72,36 @@ Deferred work. Context: Officer is collapsing from multi-tenant / open-source-re as one of the tabs (main view). - Open question: tabs vs. some other view switcher; which view is default. +## Transmission + +Phase 1 (parity with `_references/transmission-web`) shipped 2026-07-30: the `officer-transmission` +sidecar plus `/transmission` (torrents / stats / settings). Everything below is **phase 2** — none of +it exists in the reference app, so none of it was in scope for parity. + +### Probably needed regardless + +- [ ] **Resizable detail pane.** `TorrentsView` pins it at `DETAIL_HEIGHT = '45%'`, which is a guess. + Either a drag handle or a persisted height in `useLocalStorageState`, alongside the column set. +- [ ] **Revisit `DEFAULT_VISIBLE_COLUMNS`.** Eleven of thirty, chosen before ever seeing the table + rendered against the real 43-torrent library. Likely wrong in both directions. +- [ ] **Files tab on huge torrents.** `detail/FilesTab.tsx` builds the whole tree and renders every + node — no virtualisation. Fine at 14 files; unknown at a few thousand. If it stalls, the fix is + `useVirtualizer` over a flattened visible-node list, the same shape as `TorrentTable`. + +### Ideas, unprioritised + +- [ ] **Container→host path mapping.** The daemon reports its own namespace (`/downloads/complete`), + which is not a path on this host. A mapping table (`/downloads` → `~/hdds/08_TB_01/Torrents`, + `/downloadsTV` → `14_TB_02/Torrents`) would make locations clickable through to `/files` and + make "Set location" offer real destinations. Deliberately dropped from phase 1: the reference + app has no such feature, so it would have been config nothing read. +- [ ] **Push instead of poll.** Transmission RPC has no push channel, so `useTransmissionData` polls + every 5s. The sidecar could poll once and fan out over a WebSocket, which is both cheaper and + what every other live surface in the platform already does. +- [ ] **Cross-surface links.** Soulseek and `download-media` both land files in the same library; + Transmission is the third door to it. Worth a think about whether they should know about each + other at all. + ## Known bugs - [ ] **`bootstrap.ts` runs `npm install -g` for Pi on every boot.** `findPiPackageDir` checks stale diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index d37f49e6..70a8cd91 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -76,5 +76,11 @@ module.exports = { args: 'run src/servers/sidecar/headscale/index.ts', watch: false, }, + { + name: 'officer-transmission', + script: 'bun', + args: 'run src/servers/sidecar/transmission/index.ts', + watch: false, + }, ], }; diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 41a98b15..6fac29ea 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -44,6 +44,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index cf210bff..c58b9613 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -135,6 +135,7 @@ import { Activity, Radio, Network, + ArrowDownUp, } from 'lucide-react'; export const ALL_DOCK_ITEMS: DockItem[] = [ @@ -145,6 +146,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [ { label: 'Music', to: '/music', icon: Music, color: '#22c55e' }, { label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' }, { label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' }, + { label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' }, { label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' }, { label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' }, { label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' }, diff --git a/src/apps/officer-web/Screens/Dashboard/Transmission/TransmissionScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Transmission/TransmissionScreen.tsx new file mode 100644 index 00000000..3d34edf0 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Transmission/TransmissionScreen.tsx @@ -0,0 +1,63 @@ +import { useEffect, useMemo } from 'react'; +import { Navigate, useParams } from 'react-router'; +import type { LayoutNode } from 'officerdev'; +import { + WorkspaceView, + DEFAULT_TRANSMISSION_SECTION, + transmissionSectionPath, + isTransmissionSection, +} from 'officerdev'; +import { useDashboardState } from 'state/useDashboardState'; +import { defaultLayout } from './defaultLayout'; + +// /transmission uses the Workspace/Panel system (like /headscale): a filter nav on the left and the section +// view on the right. Both talk to the officer-transmission sidecar through the /api/transmission auth proxy, +// which holds no Transmission credentials of its own — the RPC URL and any Basic auth live in the sidecar. +// +// The open section is :section in the URL and the torrent filters are query params, so both panels read the +// URL with useParams/useSearchParams instead of passing state between themselves over a channel. This screen +// backs both /transmission and /transmission/:section and is the single place that decides what an absent or +// bogus section means. + +const ALLOWED_APP_TYPES = new Set(['transmission-nav', 'transmission-view', null]); + +function normalizeLayout(node: LayoutNode): LayoutNode { + if (node.type === 'panel') { + return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'transmission-view' }; + } + const children = node.children.map((c) => { + const fixed = normalizeLayout(c.node); + return fixed === c.node ? c : { ...c, node: fixed }; + }); + const changed = children.some((c, i) => c !== node.children[i]); + return changed ? { ...node, children } : node; +} + +export const TransmissionScreen = () => { + const { section } = useParams(); + const rawWorkspace = useDashboardState('screens/transmission', defaultLayout); + + const workspace = useMemo(() => { + const fixed = normalizeLayout(rawWorkspace.value); + if (fixed === rawWorkspace.value) return rawWorkspace; + return { ...rawWorkspace, value: fixed }; + }, [rawWorkspace]); + + useEffect(() => { + if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { + rawWorkspace.setValue(workspace.value); + } + }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + + // Bare /transmission, or a section that doesn't exist, resolves to a canonical URL rather than rendering a + // default while the address bar says something else — the nav highlight is derived from the URL. + if (!isTransmissionSection(section)) { + return ; + } + + return ( +
+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Transmission/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Transmission/defaultLayout.ts new file mode 100644 index 00000000..fb9ebc25 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Transmission/defaultLayout.ts @@ -0,0 +1,11 @@ +import type { LayoutNode } from 'officerdev'; + +export const defaultLayout: LayoutNode = { + type: 'group', + id: 'transmission-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'transmission-nav', appType: 'transmission-nav' }, size: 22 }, + { node: { type: 'panel', id: 'transmission-view', appType: 'transmission-view' }, size: 78 }, + ], +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Transmission/index.tsx b/src/apps/officer-web/Screens/Dashboard/Transmission/index.tsx new file mode 100644 index 00000000..bf72246e --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Transmission/index.tsx @@ -0,0 +1 @@ +export * from './TransmissionScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 2469eb7f..523933d2 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -13,6 +13,7 @@ export * from './Files'; export * from './Music'; export * from './Soulseek'; export * from './Headscale'; +export * from './Transmission'; export * from './SystemMonitor'; export * from './Activity'; export * from './CodeEditor'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 0765b87d..69539045 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -18,6 +18,7 @@ const RULES: TitleRule[] = [ { match: (p) => p.startsWith('/music'), title: 'Music' }, { match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' }, { match: (p) => p.startsWith('/headscale'), title: 'Headscale' }, + { match: (p) => p.startsWith('/transmission'), title: 'Transmission' }, { match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' }, { match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' }, { match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' }, diff --git a/src/servers/api/transmission/router.ts b/src/servers/api/transmission/router.ts new file mode 100644 index 00000000..d5f54653 --- /dev/null +++ b/src/servers/api/transmission/router.ts @@ -0,0 +1,50 @@ +import { createRouter } from '../../create-router'; +import { getTransmissionServerUrl } from './sidecar-server'; + +// Thin reverse-proxy for /api/transmission/*. The platform's ONLY job here is AUTH + FORWARDING: this router +// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards the +// subpath + query + body to the officer-transmission sidecar, which OWNS the Transmission contract and holds +// the daemon credentials. +// +// A catch-all with no routes of its own. The sidecar exposes only Officer-owned routes under /_officer/, +// because Transmission's RPC is a single POST endpoint guarded by a rotating CSRF token — proxying it raw +// would push the handshake into the browser. The full contract is documented at the top of +// src/servers/sidecar/transmission/index.ts. It is opaque from here: this file must never grow Transmission +// logic. + +export const transmissionRouter = createRouter(); + +const PREFIX = '/api/transmission'; + +transmissionRouter.all('/*', async (ctx) => { + const baseUrl = getTransmissionServerUrl(); + if (!baseUrl) return ctx.text('transmission sidecar not available', 503); + + const url = new URL(ctx.req.url); + const subpath = url.pathname.slice(PREFIX.length) || '/'; + const target = `${baseUrl}${subpath}${url.search}`; + + const method = ctx.req.method; + const headers: Record = {}; + const contentType = ctx.req.header('content-type'); + if (contentType) headers['Content-Type'] = contentType; + // Forward the authenticated user id so the sidecar can serve its Officer-owned routes. The sidecar binds + // loopback only, so this header is trusted. + headers['X-Officer-User'] = String(ctx.get('user').id); + + const hasBody = method !== 'GET' && method !== 'HEAD'; + + let upstream: Response; + try { + upstream = await fetch(target, { + method, + headers, + body: hasBody ? await ctx.req.arrayBuffer() : undefined, + }); + } catch (err) { + console.error('[transmission] proxy fetch failed', { target, error: String(err) }); + return ctx.text('transmission sidecar unreachable', 502); + } + + return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) }); +}); diff --git a/src/servers/api/transmission/sidecar-server.ts b/src/servers/api/transmission/sidecar-server.ts new file mode 100644 index 00000000..ec442925 --- /dev/null +++ b/src/servers/api/transmission/sidecar-server.ts @@ -0,0 +1,19 @@ +import * as sidecar from '@@/sidecar-registry'; + +// The officer-transmission sidecar starts its HTTP server on a random loopback port and reports it here on +// connect. We remember it so `/api/transmission/*` always forwards to the current sidecar. The platform +// holds NO knowledge of Transmission itself — not its URL, and not its credentials. + +let serverPort: number | null = null; + +sidecar.on('transmission:server', (msg) => { + const port = (msg as { port?: number }).port; + if (typeof port !== 'number') return; + serverPort = port; + console.log(`[transmission] sidecar registered on port ${port}`); +}); + +/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */ +export function getTransmissionServerUrl(): string | null { + return serverPort ? `http://127.0.0.1:${serverPort}` : null; +} diff --git a/src/servers/hono.ts b/src/servers/hono.ts index a647dd74..20199f77 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -23,6 +23,7 @@ import { musicRouter } from './api/music/router'; import { vaultRouter } from './api/vault/router'; import { slskdRouter } from './api/slskd/router'; import { headscaleRouter } from './api/headscale/router'; +import { transmissionRouter } from './api/transmission/router'; import { vpnRouter } from './api/vpn/router'; import { systemMonitorRouter } from './api/system-monitor/system-monitor'; import { activityRouter } from './api/activity/router'; @@ -30,6 +31,7 @@ import './api/music/sidecar-server'; // side-effect: capture the officer-music a import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd reverse-proxy port import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port +import './api/transmission/sidecar-server'; // side-effect: capture the officer-transmission server port import { devServerRouter, devServerProxyRouter } from './api/dev-server/router'; import { dockRouter } from './api/dock/dock'; import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations'; @@ -113,6 +115,7 @@ protectedRouter.route('/file-browser', fileBrowserRouter); protectedRouter.route('/music', musicRouter); protectedRouter.route('/slskd', slskdRouter); protectedRouter.route('/headscale', headscaleRouter); +protectedRouter.route('/transmission', transmissionRouter); protectedRouter.route('/vpn', vpnRouter); protectedRouter.route('/system-monitor', systemMonitorRouter); protectedRouter.route('/activity', activityRouter); diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index 82b9dfeb..8b020316 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -70,6 +70,8 @@ export type SidecarEvent = | { type: 'slskd:server'; port: number } // Headscale — the sidecar reports where its HTTP server is listening (random port) on connect | { type: 'headscale:server'; port: number } + // Transmission — the sidecar reports where its HTTP server is listening (random port) on connect + | { type: 'transmission:server'; port: number } // Generic | { type: 'error'; id?: string; error: string }; diff --git a/src/servers/sidecar/transmission/index.ts b/src/servers/sidecar/transmission/index.ts new file mode 100644 index 00000000..6d26c22e --- /dev/null +++ b/src/servers/sidecar/transmission/index.ts @@ -0,0 +1,127 @@ +import type { SidecarCommand, SidecarEvent } from '../protocol'; +import { createSidecarConnector } from '../connect'; +import { handleOfficerRoute } from './routes'; +import { probe } from './rpc'; +import { getTransmissionBase } from './upstream'; + +// 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 /_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 +// POST /_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. +// ───────────────────────────────────────────────────────────────────────────────────────────────── + +const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; + +/** 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); + + if (url.pathname === '/_health') { + if (!getTransmissionBase()) { + return Response.json({ ok: false, error: 'TRANSMISSION_URL not configured' }, { status: 503 }); + } + const started = Date.now(); + const result = await probe(); + return Response.json({ ...result, ms: Date.now() - started }, { status: result.ok ? 200 : 502 }); + } + + if (url.pathname.startsWith('/_officer/')) { + try { + const res = await handleOfficerRoute(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 }); + }, +}); + +console.log(`[transmission] listening on 127.0.0.1:${port} -> ${getTransmissionBase() ?? '(TRANSMISSION_URL unset)'}`); + +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', + capabilities: ['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/src/servers/sidecar/transmission/routes.ts b/src/servers/sidecar/transmission/routes.ts new file mode 100644 index 00000000..c0a011ee --- /dev/null +++ b/src/servers/sidecar/transmission/routes.ts @@ -0,0 +1,370 @@ +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. + * + * The platform injects X-Officer-User 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. + */ +export async function handleOfficerRoute(req: Request, url: URL): Promise { + const officerUser = req.headers.get('X-Officer-User'); + if (!officerUser) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 }); + + const userId = Number(officerUser); + if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User'); + + 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('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('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('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('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 }>('free-space', { path }); + return Response.json({ path: result.path, bytes: result['size-bytes'] }); +} + +async function handlePortTest(ctx: OfficerContext): Promise { + if (ctx.req.method !== 'POST') return methodNotAllowed(); + const result = await rpc<{ 'port-is-open': boolean }>('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 }>('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[] }>('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(req); + case 'action': + return await runAction(req); + case 'set': + return await setTorrent(req); + case 'location': + return await setLocation(req); + case 'rename': + return await renamePath(req); + case 'remove': + return await removeTorrents(req); + } + } + + // 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[] }>('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(req: Request): Promise { + const body = await readJson(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 }; + }>('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(req: Request): Promise { + const body = await readJson(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(method, { ids }); + return Response.json({ ok: true, affected: ids.length }); +} + +async function setTorrent(req: Request): Promise { + const body = await readJson(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('torrent-set', { ids, ...fields }); + return Response.json({ ok: true, affected: ids.length }); +} + +async function setLocation(req: Request): Promise { + const body = await readJson(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('torrent-set-location', { ids, location, move }); + return Response.json({ ok: true, affected: ids.length }); +} + +async function renamePath(req: Request): Promise { + const body = await readJson(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('torrent-rename-path', { ids: [id], path, name }); + return Response.json({ ok: true }); +} + +async function removeTorrents(req: Request): Promise { + const body = await readJson(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('torrent-remove', { ids, 'delete-local-data': deleteLocalData }); + return Response.json({ ok: true, affected: ids.length, deletedData: deleteLocalData }); +} diff --git a/src/servers/sidecar/transmission/rpc.ts b/src/servers/sidecar/transmission/rpc.ts new file mode 100644 index 00000000..29cd6f7a --- /dev/null +++ b/src/servers/sidecar/transmission/rpc.ts @@ -0,0 +1,149 @@ +import type { Torrent, TrackerStat } from './types'; +import { getAuthHeader, getRpcPath, getTransmissionBase } 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 getAuthHeader. +// • `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 the current daemon session. Module-level because it is a property of the connection, +// not of any one request, and every caller benefits from a refresh any one of them performs. +let sessionId: string | null = null; + +type RpcResponse = { result: string; arguments?: T }; + +/** + * Issue one RPC call. Retries exactly once on 409 after adopting the new session id; a second 409 means + * something other than a stale token (a proxy stripping the header, most likely) and is surfaced. + */ +export async function rpc(method: string, args: Record = {}): Promise { + return call(method, args, true); +} + +async function call(method: string, args: Record, mayRetry: boolean): Promise { + const base = getTransmissionBase(); + if (!base) throw new TransmissionError(503, 'TRANSMISSION_URL is not configured'); + + const headers: Record = { 'Content-Type': 'application/json' }; + if (sessionId) headers['X-Transmission-Session-Id'] = sessionId; + const auth = getAuthHeader(); + if (auth) headers['Authorization'] = auth; + + let res: Response; + try { + res = await fetch(`${base}${getRpcPath()}`, { + 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) { + sessionId = fresh; + return call(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. */ +export async function probe(): Promise<{ ok: boolean; version?: string; rpcVersion?: number; error?: string }> { + try { + const args = await rpc<{ version: string; 'rpc-version': number }>('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/src/servers/sidecar/transmission/types.ts b/src/servers/sidecar/transmission/types.ts new file mode 100644 index 00000000..e8e5db1e --- /dev/null +++ b/src/servers/sidecar/transmission/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/src/servers/sidecar/transmission/upstream.ts b/src/servers/sidecar/transmission/upstream.ts new file mode 100644 index 00000000..b7ec516b --- /dev/null +++ b/src/servers/sidecar/transmission/upstream.ts @@ -0,0 +1,46 @@ +// Transmission upstream config for the officer-transmission sidecar. +// +// All knowledge of the Transmission daemon (its URL, its RPC path and its credentials) lives here, +// mirroring the officer-slskd/officer-vault philosophy: the platform API is a thin auth+forward proxy and +// holds NO Transmission credentials. + +const { TRANSMISSION_URL, TRANSMISSION_USER, TRANSMISSION_PASS, TRANSMISSION_RPC_PATH } = process.env; + +let warnedUnset = false; + +/** The Transmission base URL (no trailing slash), or null when unconfigured (the sidecar then 503s). */ +export function getTransmissionBase(): string | null { + const raw = TRANSMISSION_URL?.trim(); + if (!raw) { + if (!warnedUnset) { + console.warn('[transmission] TRANSMISSION_URL is unset — the sidecar will respond 503 until it is set'); + warnedUnset = true; + } + return null; + } + return raw.replace(/\/+$/, ''); +} + +/** + * The RPC endpoint path. Transmission serves it at /transmission/rpc by default, but a reverse proxy can + * mount it anywhere, so it is configurable rather than hardcoded. + */ +export function getRpcPath(): string { + const raw = TRANSMISSION_RPC_PATH?.trim(); + if (!raw) return '/transmission/rpc'; + return raw.startsWith('/') ? raw : `/${raw}`; +} + +/** + * 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. + */ +export function getAuthHeader(): string | null { + const user = TRANSMISSION_USER?.trim(); + if (!user) return null; + const pass = TRANSMISSION_PASS ?? ''; + return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`; +} diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 0556bad0..cc8d6d08 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -12,6 +12,7 @@ import { appRegistryMetas as desktopMetas } from '../apps/Desktop'; import { appRegistryMetas as musicMetas } from '../apps/Music'; import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek'; import { appRegistryMetas as headscaleMetas } from '../apps/Headscale'; +import { appRegistryMetas as transmissionMetas } from '../apps/Transmission'; import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; import { useAppRegistry } from './useAppRegistry'; import { useUserApps } from 'state/useUserApps'; @@ -33,6 +34,7 @@ const apps = [ ...musicMetas, ...soulseekMetas, ...headscaleMetas, + ...transmissionMetas, ...monitorMetas, ]; diff --git a/src/workspaces/officerdev/src/apps/Transmission/SettingsView.tsx b/src/workspaces/officerdev/src/apps/Transmission/SettingsView.tsx new file mode 100644 index 00000000..927579a2 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Transmission/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')} +
+ +
+