transmission, extracted into plugins/transmission — the platform half
Sixth extraction, straight after photos. Everything transmission now lives at gitea.officer.dev/plugins/transmission. Deleted, not commented: the sidecar, the api router, the Transmission app (28 files including detail/ and dialogs/), the screen, the App.tsx route pair, the Dashboard barrel line, the usePageTitle rule, the AppRegistry wiring, the officerdev barrel re-exports, the permission registry entry, the hono.ts import and mount comments, and the app-store catalogue entry. No database change: transmission owns no tables. Its connection lives in service_connections, a core table it now depends on from outside — a real coupling, recorded in the plugin's own PLUGIN.md rather than left to be discovered. ── Test fixtures, repointed rather than deleted ── Transmission was the canonical `members: 'none'` fixture — a single-tenant daemon with no user concept — across three app-store test files. slskd has the identical shape, so the assertions moved there. Deleting them would have quietly dropped coverage of the no-members install path along with the plugin, which is the same trap the vault extraction hit with `members: 'invite'`. ── Vocabulary swept, per the runbook ── Six screens and two App.tsx comments pointed at `/photos` or `/transmission` as the worked example of the Workspace/Panel shape. Both are plugins now, so those pointers dangled at code no longer in the tree. Repointed at /command-hq and /soulseek, which are core. bunx tsgo clean. 1080 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,91 +0,0 @@
|
||||
import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
|
||||
import { probe } from './rpc';
|
||||
import {
|
||||
basicAuth,
|
||||
getTransmissionConfig,
|
||||
invalidateTransmissionConfig,
|
||||
normalizeBase,
|
||||
normalizeRpcPath,
|
||||
} from './upstream';
|
||||
|
||||
// `/_config` — where the Transmission daemon is, driven from the app rather than from a shell on the server.
|
||||
//
|
||||
// ONE connection, not a registry: nobody runs two Transmission daemons. So there is no label, no id in the
|
||||
// path and no activate route — a save is an upsert over the single row, and the UI is one form.
|
||||
//
|
||||
// The URL is the only thing usually needed. Most Transmission daemons run with no RPC auth at all, so
|
||||
// username/password are optional and an EMPTY username means "no auth" rather than "empty credentials" —
|
||||
// see basicAuth in upstream.ts, where sending a Basic header anyway makes the daemon reject the request.
|
||||
// `rpcPath` is there for the reverse-proxy case and defaults to /transmission/rpc.
|
||||
//
|
||||
// The password is WRITE-ONLY across this boundary: the GET reports whether one is stored, never its value.
|
||||
// A save is validated with a live session-get first, so a wrong URL or rejected credentials is a 400 with
|
||||
// the reason rather than a stored row that makes every later screen fail mysteriously.
|
||||
|
||||
/** What the browser is allowed to know about the connection. Never includes the password. */
|
||||
async function connectionState(userId: number): Promise<Response> {
|
||||
const connection = await getServiceConnection(userId, 'transmission');
|
||||
return Response.json({ configured: !!connection, connection });
|
||||
}
|
||||
|
||||
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
||||
|
||||
type ConnectionBody = { url?: unknown; username?: unknown; password?: unknown; rpcPath?: unknown };
|
||||
|
||||
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
|
||||
|
||||
const readString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
/** Save the connection: validated against the live daemon, then stored. */
|
||||
async function save(req: Request, userId: number): Promise<Response> {
|
||||
const body = ((await req.json().catch(() => null)) as ConnectionBody | null) ?? {};
|
||||
const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
|
||||
const username = readString(body.username);
|
||||
const rpcPath = normalizeRpcPath(readString(body.rpcPath));
|
||||
// A blank password with a username still set means "keep the stored one", so changing the URL does not
|
||||
// need the password re-typed. Clearing the username clears the auth entirely.
|
||||
const password = typeof body.password === 'string' && body.password ? body.password : null;
|
||||
|
||||
if (!url) return bad('url is required');
|
||||
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
|
||||
|
||||
const current = await getServiceConnection(userId, 'transmission');
|
||||
const keepPassword = !password && !!username && username === current?.username && current.hasSecret;
|
||||
|
||||
// Probing needs the real password, so a save that keeps the stored one borrows it through the normal read
|
||||
// path rather than reaching for the ciphertext here.
|
||||
const stored = keepPassword ? await getTransmissionConfig(userId) : null;
|
||||
const auth = keepPassword ? (stored?.auth ?? null) : basicAuth(username || null, password);
|
||||
|
||||
const result = await probe({ base: url, rpcPath, auth });
|
||||
if (!result.ok) return bad(result.error ?? 'could not reach the daemon');
|
||||
|
||||
const connection = await saveServiceConnection({
|
||||
userId,
|
||||
service: 'transmission',
|
||||
url,
|
||||
username: username || null,
|
||||
// undefined keeps the stored password; null clears it, which is what dropping the username means.
|
||||
secret: keepPassword ? undefined : (password ?? null),
|
||||
path: rpcPath,
|
||||
version: result.version ?? null,
|
||||
});
|
||||
invalidateTransmissionConfig(userId);
|
||||
return Response.json({ connection, rpcVersion: result.rpcVersion ?? null });
|
||||
}
|
||||
|
||||
/** `/_config` — GET the connection, PUT/POST to save it, DELETE to forget it. */
|
||||
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
|
||||
if (subpath && subpath !== '/') return bad('not found', 404);
|
||||
|
||||
if (req.method === 'GET') return connectionState(userId);
|
||||
if (req.method === 'POST' || req.method === 'PUT') return save(req, userId);
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
await deleteServiceConnection(userId, 'transmission');
|
||||
invalidateTransmissionConfig(userId);
|
||||
return connectionState(userId);
|
||||
}
|
||||
|
||||
return bad('method not allowed', 405);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { handleConfigRoute } from './config';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { probe } from './rpc';
|
||||
import { getTransmissionConfig } from './upstream';
|
||||
import { API_URL } from '../../officer-url.mjs';
|
||||
|
||||
// The officer-transmission sidecar. Owns the whole Transmission contract for Officer: the daemon URL and
|
||||
// credentials, the X-Transmission-Session-Id CSRF handshake, and the translation from Transmission's
|
||||
// single-endpoint RPC vocabulary into real REST paths. The platform API is a thin auth-gated forwarder
|
||||
// (src/servers/api/transmission/router.ts) holding no Transmission credentials.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP CONTRACT — the platform strips its /api/transmission mount prefix before forwarding.
|
||||
//
|
||||
// GET /_health ours. Probes the daemon with a cheap session-get.
|
||||
// GET /_config { configured, connection } — where the daemon is, never the password
|
||||
// PUT /_config { url, username?, password?, rpcPath? } — validated with a live
|
||||
// session-get, then stored. A blank password keeps the stored one
|
||||
// DEL /_config forget the connection
|
||||
// GET /_officer/session full session settings + version + rpc-version
|
||||
// POST /_officer/session write a partial settings object (whitelisted), returns it read back
|
||||
// GET /_officer/stats session-stats: cumulative + current-session counters
|
||||
// GET /_officer/torrents the list poll — LIST_FIELDS for every torrent, decorated
|
||||
// GET /_officer/torrents/:id one torrent with files/peers/trackers (DETAIL_FIELDS)
|
||||
// POST /_officer/torrents/add {metainfo|filename, downloadDir?, labels?, paused?, …}
|
||||
// → {status:'added'|'duplicate', torrent}
|
||||
// POST /_officer/torrents/action {ids, action} — start|start-now|stop|verify|reannounce|queue-*
|
||||
// POST /_officer/torrents/set {ids, …whitelisted torrent-set fields}
|
||||
// POST /_officer/torrents/location {ids, location, move}
|
||||
// POST /_officer/torrents/rename {id, path, name}
|
||||
// POST /_officer/torrents/remove {ids, deleteLocalData}
|
||||
// GET /_officer/free-space?path= free space at a path
|
||||
// GET /_officer/port-test is the peer port reachable from outside
|
||||
// POST /_officer/blocklist-update refresh the blocklist, returns the new size
|
||||
// anything else 404
|
||||
//
|
||||
// There is deliberately NO transparent /transmission/rpc passthrough. Transmission multiplexes every verb
|
||||
// through one POST body, reports failures as `{"result": "<error>"}` inside a 200, and demands a CSRF token
|
||||
// that rotates on every daemon restart. Proxying that raw would push all three into the browser. Every
|
||||
// quirk is absorbed here — see rpc.ts.
|
||||
//
|
||||
// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind
|
||||
// loopback only, so its presence is the trust signal — a request without it did not come through the
|
||||
// platform, and which daemon to talk to is per-owner data.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
const p = probeServer.port;
|
||||
probeServer.stop(true);
|
||||
if (p == null) throw new Error('failed to acquire a free port');
|
||||
return p;
|
||||
}
|
||||
|
||||
const port = getFreePort();
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
// A base64 .torrent for a large multi-file release travels in the request body of /torrents/add.
|
||||
maxRequestBodySize: 256 * 1024 * 1024,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
const officerUser = req.headers.get('X-Officer-User');
|
||||
const userId = Number(officerUser);
|
||||
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
|
||||
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
|
||||
try {
|
||||
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
|
||||
} catch (err) {
|
||||
console.error(`[transmission] ${req.method} ${url.pathname} failed`, err);
|
||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
|
||||
// configured-but-unreachable daemon is the whole reason the flag is on the response.
|
||||
if (url.pathname === '/_health') {
|
||||
const cfg = await getTransmissionConfig(userId);
|
||||
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
|
||||
const started = Date.now();
|
||||
const result = await probe(cfg);
|
||||
return Response.json(
|
||||
{ ...result, configured: true, ms: Date.now() - started },
|
||||
{ status: result.ok ? 200 : 502 },
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/_officer/')) {
|
||||
try {
|
||||
const res = await handleOfficerRoute(userId, req, url);
|
||||
if (res) return res;
|
||||
return Response.json({ error: 'not found' }, { status: 404 });
|
||||
} catch (err) {
|
||||
console.error(`[transmission] ${req.method} ${url.pathname} failed`, err);
|
||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ error: 'not found' }, { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
// No upstream in the banner: where the daemon lives is now per-owner state read from the database per
|
||||
// request, not a constant this process knows at boot.
|
||||
console.log(`[transmission] listening on 127.0.0.1:${port}`);
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'transmission',
|
||||
handles: ['transmission'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
onConnected() {
|
||||
connection.send({ type: 'transmission:server', port });
|
||||
console.log(`[transmission] reported server port ${port} to API`);
|
||||
},
|
||||
});
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[transmission] ${signal} received, shutting down...`);
|
||||
try {
|
||||
server.stop(true);
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -1,367 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import type { Torrent, TrackerStat } from './types';
|
||||
import type { TransmissionConfig } from './upstream';
|
||||
import { getTransmissionConfig } from './upstream';
|
||||
|
||||
// The Transmission RPC call layer. Every upstream request in this sidecar goes through here, so the
|
||||
// wire-level quirks are handled exactly once:
|
||||
//
|
||||
// • THE 409 HANDSHAKE. Transmission's CSRF defence answers the first request of a session with
|
||||
// `409 Conflict` and an `X-Transmission-Session-Id` header, expecting the client to repeat the request
|
||||
// carrying it. The token also rotates whenever the daemon restarts, so this is not a start-up ritual you
|
||||
// can do once — any call can 409 at any time. Absorbing it here is the single biggest reason this
|
||||
// sidecar exists: a client that handles it naively works until the daemon is restarted and then fails
|
||||
// in a way that looks like an auth bug.
|
||||
// • Errors are NOT signalled by HTTP status. A perfectly successful-looking 200 carries
|
||||
// `{"result": "some error string"}`; only `result === 'success'` means it worked.
|
||||
// • Auth is HTTP Basic, and an EMPTY username must send no header at all — see basicAuth in upstream.ts.
|
||||
// • `torrent-get` with an unknown field name fails the whole call rather than ignoring the field, so
|
||||
// LIST_FIELDS/DETAIL_FIELDS are curated against the running daemon's rpc-version, not guessed.
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */
|
||||
export class TransmissionError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TransmissionError';
|
||||
}
|
||||
}
|
||||
|
||||
// The CSRF token for a daemon session, keyed by endpoint rather than by owner: the token belongs to the
|
||||
// daemon, so two owners pointed at the same one share a refresh, and re-pointing at a different daemon
|
||||
// cannot carry a token that daemon never issued.
|
||||
const sessionIds = new Map<string, string>();
|
||||
|
||||
type RpcResponse<T> = { result: string; arguments?: T };
|
||||
|
||||
/** Issue one RPC call against the owner's configured daemon. */
|
||||
export async function rpc<T = unknown>(userId: number, method: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
const cfg = await getTransmissionConfig(userId);
|
||||
if (!cfg) throw new TransmissionError(503, 'transmission is not connected');
|
||||
return call<T>(cfg, method, args, true);
|
||||
}
|
||||
|
||||
/** The same call against a connection that may not be stored yet — used to validate one before saving it. */
|
||||
export async function rpcWith<T = unknown>(
|
||||
cfg: TransmissionConfig,
|
||||
method: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
return call<T>(cfg, method, args, true);
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
cfg: TransmissionConfig,
|
||||
method: string,
|
||||
args: Record<string, unknown>,
|
||||
mayRetry: boolean,
|
||||
): Promise<T> {
|
||||
const endpoint = `${cfg.base}${cfg.rpcPath}`;
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const sessionId = sessionIds.get(endpoint);
|
||||
if (sessionId) headers['X-Transmission-Session-Id'] = sessionId;
|
||||
if (cfg.auth) headers['Authorization'] = cfg.auth;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ method, arguments: args }),
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error && err.name === 'TimeoutError' ? 'timed out' : 'unreachable';
|
||||
throw new TransmissionError(502, `transmission upstream ${reason}`);
|
||||
}
|
||||
|
||||
if (res.status === 409) {
|
||||
const fresh = res.headers.get('X-Transmission-Session-Id');
|
||||
if (fresh && mayRetry) {
|
||||
sessionIds.set(endpoint, fresh);
|
||||
return call<T>(cfg, method, args, false);
|
||||
}
|
||||
throw new TransmissionError(502, 'transmission rejected the session id handshake');
|
||||
}
|
||||
|
||||
if (res.status === 401) throw new TransmissionError(401, 'transmission rejected the credentials');
|
||||
if (!res.ok) throw new TransmissionError(502, `transmission returned ${res.status}`);
|
||||
|
||||
const body = (await res.json().catch(() => null)) as RpcResponse<T> | null;
|
||||
if (!body) throw new TransmissionError(502, 'transmission returned an unreadable body');
|
||||
// A 200 with a non-'success' result is the normal way Transmission reports a failure.
|
||||
if (body.result !== 'success') throw new TransmissionError(502, body.result || 'transmission reported a failure');
|
||||
|
||||
return (body.arguments ?? {}) as T;
|
||||
}
|
||||
|
||||
/** Reachability + credential probe, used by /_health and before a connection is saved. */
|
||||
export async function probe(
|
||||
cfg: TransmissionConfig,
|
||||
): Promise<{ ok: boolean; version?: string; rpcVersion?: number; error?: string }> {
|
||||
try {
|
||||
const args = await rpcWith<{ version: string; 'rpc-version': number }>(cfg, 'session-get', {
|
||||
fields: ['version', 'rpc-version'],
|
||||
});
|
||||
return { ok: true, version: args.version, rpcVersion: args['rpc-version'] };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Derived fields ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Subdomain prefixes that carry no identity — `tracker.example.org` and `t.example.org` are the same
|
||||
// operator to a human grouping their torrents, and showing them as two entries makes the filter useless.
|
||||
const IGNORED_TRACKER_PREFIXES = new Set(['t', 'tr', 'tk', 'tracker', 'bt', 'open', 'opentracker', 'pt']);
|
||||
|
||||
const ANNOUNCE_STATE: Record<number, string> = {
|
||||
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;
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
// 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<string, unknown> & {
|
||||
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;
|
||||
@@ -1,74 +0,0 @@
|
||||
import { getServiceCredentials } from 'officerdb';
|
||||
|
||||
// Transmission upstream config for the officer-transmission sidecar.
|
||||
//
|
||||
// All knowledge of the daemon — its URL, its RPC path and its credentials — lives here. The platform API is
|
||||
// a thin auth+forward proxy and holds NO Transmission credentials.
|
||||
//
|
||||
// The daemon is CONFIGURED BY THE OWNER FROM THE UI and stored in `service_connections` (see
|
||||
// databases/officer_db/src/service-connections/queries.ts), no longer read from the environment: Bun
|
||||
// auto-loads `.env` into every process started in the platform directory, so TRANSMISSION_* was also
|
||||
// sitting in `officer`'s own process.env, and pointing Officer at a daemon meant editing a file on the
|
||||
// server. Nothing in this file reads process.env.
|
||||
|
||||
/** Everything needed to make one RPC call. A candidate being validated has this and nothing else yet. */
|
||||
export type TransmissionConfig = { base: string; rpcPath: string; auth: string | null };
|
||||
|
||||
/** Trailing slashes off, so `${base}${rpcPath}` never doubles the separator. */
|
||||
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
|
||||
|
||||
/**
|
||||
* The RPC endpoint path. Transmission serves it at /transmission/rpc by default, but a reverse proxy can
|
||||
* mount it anywhere, so it is stored per connection rather than hardcoded.
|
||||
*/
|
||||
export const normalizeRpcPath = (raw: string | null | undefined): string => {
|
||||
const path = raw?.trim();
|
||||
if (!path) return '/transmission/rpc';
|
||||
return path.startsWith('/') ? path : `/${path}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `Authorization: Basic …` header value, or null when the daemon has no auth configured.
|
||||
*
|
||||
* Transmission treats an empty username as "no authentication required" — sending an empty Basic header in
|
||||
* that case is not merely useless, it makes the daemon reject the request. So this returns null unless a
|
||||
* username is actually set, which is why the stored username is nullable rather than defaulting to ''.
|
||||
*/
|
||||
export function basicAuth(username: string | null, password: string | null): string | null {
|
||||
const user = username?.trim();
|
||||
if (!user) return null;
|
||||
return `Basic ${Buffer.from(`${user}:${password ?? ''}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
// The torrent list polls every couple of seconds, so the row is cached rather than re-read per request.
|
||||
// Writes invalidate immediately; the TTL only covers someone editing the row in psql, which then takes
|
||||
// effect within a minute instead of needing a restart.
|
||||
const TTL_MS = 60_000;
|
||||
const cache = new Map<number, { cfg: TransmissionConfig | null; at: number }>();
|
||||
|
||||
/**
|
||||
* The owner's Transmission connection, or null when nothing is configured — the sidecar then answers 503,
|
||||
* and the UI turns that into the setup form rather than an empty torrent list.
|
||||
*/
|
||||
export async function getTransmissionConfig(userId: number): Promise<TransmissionConfig | null> {
|
||||
const hit = cache.get(userId);
|
||||
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
|
||||
|
||||
const creds = await getServiceCredentials(userId, 'transmission');
|
||||
// `url` became nullable when services that inherit one instance landed (see the schema column note).
|
||||
// Transmission fronts a single daemon and always stores its own, so null here is a malformed row.
|
||||
const cfg = creds?.url
|
||||
? {
|
||||
base: normalizeBase(creds.url),
|
||||
rpcPath: normalizeRpcPath(creds.path),
|
||||
auth: basicAuth(creds.username, creds.secret),
|
||||
}
|
||||
: null;
|
||||
cache.set(userId, { cfg, at: Date.now() });
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Drop the cached row — called by the config routes after any save or removal. */
|
||||
export function invalidateTransmissionConfig(userId: number): void {
|
||||
cache.delete(userId);
|
||||
}
|
||||
Reference in New Issue
Block a user