diff --git a/api/router.ts b/api/router.ts index cdb3f45..3c8ea08 100644 --- a/api/router.ts +++ b/api/router.ts @@ -1,6 +1,7 @@ import { createRouter } from '@@/create-router'; import { createSidecarProxy } from '@@/sidecar/create-proxy'; import { getLinkwardenConfig, setLinkwardenConfig, getAccount } from '../db/queries'; +import { stripHopByHop } from '../sidecar/upstream'; /** * Mounted at `/api/linkwarden`. @@ -32,7 +33,51 @@ const proxy = createSidecarProxy({ timeoutSeconds: 120, }); -router.route('/lw', proxy.router); +/* + ── The factory's ROUTER is not used, and that is the whole reason this is written out ── + + `createSidecarProxy` is for AUTHENTICATED proxying: its forwarder does `ctx.get('user').id`, because + every other sidecar behind it is reached by somebody with an Officer session. This door is declared in + `unauthenticatedPaths`, so there is no user, and mounting the factory's router here answered every + request with `TypeError: undefined is not an object` as a 500. Measured on alpha, not reasoned about. + + Vault hand-writes its public router for exactly this reason and I should have followed it rather than + reaching for the shared thing because it was shared. + + What the factory IS still used for, and why it is still constructed: `getHttpUrl()`, so the port the + sidecar announced is discovered in one place; and `registerProxiedPrefix`, which runs at construction + and tells the body parser to leave `/api/linkwarden/lw` alone. Both matter, and neither is the router. +*/ +router.all('/lw/*', async (ctx) => { + const base = proxy.getHttpUrl(); + if (!base) return ctx.text('the Linkwarden sidecar is not running', 503); + + const url = new URL(ctx.req.url); + const subpath = url.pathname.slice('/api/linkwarden/lw'.length) || '/'; + + /* + Authorization is FORWARDED, not replaced — the opposite of the door below. + + The client's own credential is the only one in play here, and Linkwarden is the only thing entitled + to judge it. Officer adds no security to a stock extension because it has nothing to check: this is + the same exposure through one address instead of two, which is what lets Linkwarden stop listening + on its own. + */ + const hasBody = ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD'; + const init: RequestInit & { duplex?: 'half' } = { + method: ctx.req.method, + headers: stripHopByHop(ctx.req.raw.headers), + redirect: 'manual', + ...(hasBody ? { body: ctx.req.raw.body, duplex: 'half' as const } : {}), + }; + + try { + const upstream = await fetch(`${base}${subpath}${url.search}`, init); + return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); + } catch { + return ctx.text('Linkwarden unreachable', 502); + } +}); // ── where the instance is ────────────────────────────────────────────────────────────────────────