Files
pastilhasandClaude Opus 5 864dd16355 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 <noreply@anthropic.com>
2026-08-18 19:19:30 +00:00

368 lines
15 KiB
TypeScript

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<Record<string, unknown> | null> {
const body = await req.json().catch(() => null);
return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : null;
}
/** Torrent ids as Transmission wants them: a number array. Rejects anything that isn't a positive integer. */
function readIds(body: Record<string, unknown>): 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<string, string> = {
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<string, unknown>, allowed: Set<string>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<Response | null> {
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<Response | null> {
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<SessionSettings>(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<SessionSettings>(ctx.userId, 'session-get');
return Response.json({ session });
}
return methodNotAllowed();
}
async function handleStats(ctx: OfficerContext): Promise<Response> {
if (ctx.req.method !== 'GET') return methodNotAllowed();
const stats = await rpc<SessionStats>(ctx.userId, 'session-stats');
return Response.json({ stats });
}
async function handleFreeSpace(ctx: OfficerContext): Promise<Response> {
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<Response> {
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<Response> {
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<Response | null> {
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<Response> {
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON object body');
const args: Record<string, unknown> = {};
// 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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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 });
}