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>
368 lines
15 KiB
TypeScript
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 });
|
|
}
|