import { createRouter } from '@@/create-router'; import { createSidecarProxy } from '@@/sidecar/create-proxy'; import { getLinkwardenConfig, setLinkwardenConfig, getAccount } from '../db/queries'; /** * Mounted at `/api/linkwarden`. * * Three things live here and they are worth telling apart, because two of them look the same from the * outside and are not: * * `/lw/*` the extension's door. Unauthenticated, forwarded verbatim, credential is the caller's. * `/api/*` OUR door. Officer session required; we swap in the caller's Linkwarden token. * `/config` where the instance is. Owner only, by the manifest. */ export const router = createRouter(); /* The extension's door. `proxiedSubPath` is the load-bearing argument. `registerProxiedPrefix` tells the body parser to leave a prefix alone — right for forwarded traffic, and required for uploads — but this plugin serves its own routes under the same prefix, and those need their bodies parsed. Claiming the whole of `/api/linkwarden` would make every field on `/config` read as missing, which is the failure caldav hit on the day it was extracted. So the proxy claims `/lw` and nothing else. */ const proxy = createSidecarProxy({ name: 'linkwarden', prefix: '/api/linkwarden', proxiedSubPath: '/lw', permissionKey: 'linkwarden', // Archiving a page can take a while, and the extension waits on it. timeoutSeconds: 120, }); router.route('/lw', proxy.router); // ── where the instance is ──────────────────────────────────────────────────────────────────────── router.get('/config', async (ctx) => { const config = await getLinkwardenConfig(); // The admin token is never returned, only whether one is set — see `getAdminToken` for why it is read // through a separate function rather than being a field on this. return ctx.json({ url: config.url, enabled: config.enabled, updatedAt: config.updatedAt }); }); router.put('/config', async (ctx) => { const body = (await ctx.req.json().catch(() => null)) as { url?: string | null; enabled?: boolean; adminToken?: string | null; } | null; if (!body) return ctx.json({ error: 'expected a JSON object' }, 400); if (body.url !== undefined && body.url !== null && !/^https?:\/\//i.test(body.url.trim())) { return ctx.json({ error: 'the address must start with http:// or https://' }, 400); } const config = await setLinkwardenConfig(body); return ctx.json({ url: config.url, enabled: config.enabled, updatedAt: config.updatedAt }); }); /** * Can we actually reach it, and does the credential work? * * Separate from `/config` because "the owner typed an address" and "there is a Linkwarden there" are * different facts, and a settings panel that conflates them makes a typo look like an outage. Asked of * the sidecar rather than from here: it is the thing that will have to reach it, and its answer is the * one that matters. */ router.get('/config/health', async (ctx) => { const base = proxy.getHttpUrl(); if (!base) return ctx.json({ ok: false, reason: 'sidecar not running' }, 503); try { const probe = await fetch(`${base}/_health`, { signal: AbortSignal.timeout(8000) }); return ctx.json(await probe.json(), probe.status as 200); } catch { return ctx.json({ ok: false, reason: 'sidecar unreachable' }, 502); } }); // ── our own door ───────────────────────────────────────────────────────────────────────────────── /** * Linkwarden's API, as the calling Officer user. * * ── Why this exists next to `/lw`, which already forwards ── * * `/lw` forwards the caller's own credential, which is right for the extension and impossible for us: a * browser holding an Officer session has no Linkwarden token and must never be given one. So this route * looks up who is calling, finds their mapped account, and puts THEIR token on the request. The device * holds an Officer session and nothing else, which is the same property the Vault plugin exists to give. * * The permission model stays Linkwarden's. Officer decides whose token goes on; Linkwarden decides what * that token may see. Re-implementing the second half here would be a second answer able to disagree * with the first. */ router.all('/api/*', async (ctx) => { const userId = ctx.get('user')?.id; if (!userId) return ctx.json({ error: 'unauthenticated' }, 401); const base = proxy.getHttpUrl(); if (!base) return ctx.json({ error: 'the Linkwarden sidecar is not running' }, 503); const account = await getAccount(userId); if (!account) { // Named rather than a bare 403: "you have no Linkwarden account" is something the owner can fix, and // a message that says so is the difference between a support question and a self-service one. return ctx.json({ error: 'no Linkwarden account is mapped to you — ask the owner to create one' }, 403); } if (!account.accessToken) { return ctx.json({ error: 'your Linkwarden account has no access token yet' }, 403); } const url = new URL(ctx.req.url); // `/api/linkwarden/api/v1/links` is Linkwarden's `/api/v1/links`. const subpath = url.pathname.slice('/api/linkwarden'.length) || '/'; const headers = new Headers(ctx.req.raw.headers); headers.delete('cookie'); // an Officer session cookie means nothing upstream and should not travel headers.set('Authorization', `Bearer ${account.accessToken}`); const hasBody = ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD'; const init: RequestInit & { duplex?: 'half' } = { method: ctx.req.method, 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: upstream.headers }); } catch { return ctx.json({ error: 'Linkwarden unreachable' }, 502); } });