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(); type RpcResponse = { result: string; arguments?: T }; /** Issue one RPC call against the owner's configured daemon. */ export async function rpc(userId: number, method: string, args: Record = {}): Promise { const cfg = await getTransmissionConfig(userId); if (!cfg) throw new TransmissionError(503, 'transmission is not connected'); return call(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( cfg: TransmissionConfig, method: string, args: Record = {}, ): Promise { return call(cfg, method, args, true); } async function call( cfg: TransmissionConfig, method: string, args: Record, mayRetry: boolean, ): Promise { const endpoint = `${cfg.base}${cfg.rpcPath}`; const headers: Record = { '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(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 | 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 = { 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; }