`createSidecarProxy` is for AUTHENTICATED proxying. Its forwarder does
`ctx.get('user').id`, because every other sidecar behind it is reached by
somebody holding an Officer session. This door is declared in
`unauthenticatedPaths`, so there is no user, and mounting the factory's router
there answered every request with `TypeError: undefined is not an object` as a
500.
Found on alpha with a real request rather than by reading: the authenticated door
returned 401 as it should and the extension door returned 500.
Vault hand-writes its public router for exactly this reason. I reached for the
shared thing because it was shared, which is the wrong reason - it is shared
among routes that have a caller, and this one deliberately does not.
The factory is still constructed and still earns it: getHttpUrl(), so the port
the sidecar announced is learned in one place, and registerProxiedPrefix, which
runs at construction and keeps the body parser off /api/linkwarden/lw. Neither of
those is the router.
182 lines
8.3 KiB
TypeScript
182 lines
8.3 KiB
TypeScript
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`.
|
|
*
|
|
* 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,
|
|
});
|
|
|
|
/*
|
|
── 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 ────────────────────────────────────────────────────────────────────────
|
|
|
|
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);
|
|
}
|
|
});
|