add a transmission sidecar and ui
officer-transmission is a new pm2 peer that owns the transmission rpc
connection and exposes a curated /_officer/* contract instead of proxying
raw rpc. it absorbs the three quirks callers otherwise have to know about:
the 409 x-transmission-session-id handshake, failures returned as
{"result": "..."} inside http 200, and basic auth where an empty username
must send no header at all.
/transmission is the ui, on the workspace/panel framework: a filter nav and
three sections (torrents, stats, settings). the torrent list is virtualised
with 30 available columns, multi-select, and a right-click menu; the detail
pane covers general, files as a real tree, peers and trackers. filters and
the open torrent live in the url, so a filtered view is a link.
phase 1 goal was parity with _references/transmission-web. follow-up work is
recorded in TODO.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getTransmissionServerUrl } from './sidecar-server';
|
||||
|
||||
// Thin reverse-proxy for /api/transmission/*. The platform's ONLY job here is AUTH + FORWARDING: this router
|
||||
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards the
|
||||
// subpath + query + body to the officer-transmission sidecar, which OWNS the Transmission contract and holds
|
||||
// the daemon credentials.
|
||||
//
|
||||
// A catch-all with no routes of its own. The sidecar exposes only Officer-owned routes under /_officer/,
|
||||
// because Transmission's RPC is a single POST endpoint guarded by a rotating CSRF token — proxying it raw
|
||||
// would push the handshake into the browser. The full contract is documented at the top of
|
||||
// src/servers/sidecar/transmission/index.ts. It is opaque from here: this file must never grow Transmission
|
||||
// logic.
|
||||
|
||||
export const transmissionRouter = createRouter();
|
||||
|
||||
const PREFIX = '/api/transmission';
|
||||
|
||||
transmissionRouter.all('/*', async (ctx) => {
|
||||
const baseUrl = getTransmissionServerUrl();
|
||||
if (!baseUrl) return ctx.text('transmission sidecar not available', 503);
|
||||
|
||||
const url = new URL(ctx.req.url);
|
||||
const subpath = url.pathname.slice(PREFIX.length) || '/';
|
||||
const target = `${baseUrl}${subpath}${url.search}`;
|
||||
|
||||
const method = ctx.req.method;
|
||||
const headers: Record<string, string> = {};
|
||||
const contentType = ctx.req.header('content-type');
|
||||
if (contentType) headers['Content-Type'] = contentType;
|
||||
// Forward the authenticated user id so the sidecar can serve its Officer-owned routes. The sidecar binds
|
||||
// loopback only, so this header is trusted.
|
||||
headers['X-Officer-User'] = String(ctx.get('user').id);
|
||||
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, {
|
||||
method,
|
||||
headers,
|
||||
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[transmission] proxy fetch failed', { target, error: String(err) });
|
||||
return ctx.text('transmission sidecar unreachable', 502);
|
||||
}
|
||||
|
||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
||||
});
|
||||
Reference in New Issue
Block a user