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