Files
gitea/api/router.ts
T
officer e3f673ecb9 log why the frame fetch failed
'gitea unreachable' on its own is not a diagnosis. The sidecar was answering
200 to a direct curl while the panel showed that message, which sent the search
to the wrong half of the system twice.
2026-08-19 09:09:07 +00:00

157 lines
7.2 KiB
TypeScript

import { randomBytes } from 'node:crypto';
import { createRouter } from '@@/create-router';
import { createSidecarProxy } from '@@/sidecar/create-proxy';
import { mountPrefix } from '@@/plugins/manifest';
import { manifest } from '../manifest';
// Two doors, because an iframe cannot carry a bearer token.
//
// ── The ordinary door ──
//
// `/_config`, `/_health` and `/_api/*` are normal API calls from the panel. They go through the
// platform's gate like any plugin's, and `createSidecarProxy` forwards them.
//
// ── The iframe door ──
//
// Gitea already has a web interface, and this plugin renders it rather than reimplementing it. That
// means an `<iframe>`, and an iframe's sub-resources — every stylesheet, script and avatar — are fetched
// by the browser with no Authorization header. So `/_web/*` is served ABOVE the platform's gate
// (`unauthenticatedPaths` in the manifest) and authenticates itself with a cookie instead.
//
// The cookie holds a MINTED token, not a constant. `dev-proxy` sets `=1`, which is fine for a
// tailnet-only owner-gated preview but means anyone who knows the cookie name can set it themselves.
// Here the value is 32 random bytes, held in memory against the user who asked for it, and it expires.
//
// What it protects is narrow, and worth being honest about: Gitea authenticates the person itself, with
// its own session cookie, so an unauthenticated caller reaching `/_web/` sees Gitea's login page and
// nothing else. This stops Officer being used as an open proxy to it — not a data leak, a manners
// problem, but a cheap one to fix.
const PREFIX = `/api${mountPrefix({ appName: 'gitea', manifest })}`;
const proxy = createSidecarProxy({ name: 'gitea', prefix: PREFIX });
/**
* OUR routes first, then the proxy's catch-all.
*
* `createSidecarProxy` registers `all('/*')` when it is CALLED, so anything added to `proxy.router`
* afterwards is dead — hono matches in registration order and the wildcard has already claimed
* everything. Both routes below were added that way at first, and the symptom was
* `POST /session -> 500`: forwarded to a sidecar that has no such route, reported as "internal error",
* and looking for all the world like the sidecar being broken.
*
* So this is a router of our own, with the proxy's mounted UNDER it at the end.
*/
const router_ = createRouter();
/** Mint the frame cookie for the iframe, which cannot carry a bearer token. */
router_.post('/session', (ctx) => {
const user = ctx.get('user') as { id: number } | undefined;
if (!user) return ctx.json({ error: 'not authenticated' }, 401);
ctx.header('set-cookie', mintFrameCookie(user.id, PREFIX));
return ctx.json({ ok: true });
});
/**
* The iframe door, forwarded by hand rather than through `createSidecarProxy`.
*
* That proxy forwards exactly three headers — `content-type`, `range`, `if-none-match` — and nothing
* else, on purpose: it is the shared forwarder for every sidecar and its restraint is what makes it safe
* to reuse. **It strips cookies**, and this path is nothing but cookies. Loosening it for gitea would
* loosen it for the wallet.
*
* So this one path forwards the browser's request itself, headers and all. Everything else about the
* plugin still goes through the shared proxy.
*/
router_.all('/_web/*', async (ctx) => {
const userId = frameUser(ctx.req.header('cookie') ?? null);
// Deliberately terse. Whoever reaches this without a cookie is a stray request, not a person — the
// panel mints one before the frame loads.
if (userId === null) return ctx.text('no gitea frame session', 401);
const sidecar = proxy.getHttpUrl();
if (!sidecar) return ctx.text('gitea sidecar not available', 503);
const url = new URL(ctx.req.url);
const subpath = url.pathname.slice(PREFIX.length); // keeps the `/_web` the sidecar routes on
const headers = new Headers(ctx.req.raw.headers);
for (const h of ['host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'authorization']) {
headers.delete(h);
}
// The sidecar binds loopback and trusts these, which is exactly why nothing but this may set them.
headers.set('X-Officer-User', String(userId));
// Where the browser thinks the frame lives. The sidecar rewrites Gitea's HTML, redirects and cookies
// against it and has no other way to know — it must not hardcode how the platform routes to it, and
// `mountPrefix` is the only thing entitled to decide that.
headers.set('X-Officer-Frame-Base', `${PREFIX}/_web`);
const method = ctx.req.method;
const hasBody = method !== 'GET' && method !== 'HEAD';
try {
const upstream = await fetch(`${sidecar}${subpath}${url.search}`, {
method,
headers,
...(hasBody ? { body: await ctx.req.raw.arrayBuffer() } : {}),
// The sidecar has already rewritten any Location for the frame; following it here would fetch a
// page the browser never asked for and lose the redirect it needs to see.
redirect: 'manual',
});
return new Response(upstream.body, { status: upstream.status, headers: upstream.headers });
} catch (err) {
// Logged, not swallowed. "unreachable" on its own sent two people to the wrong half of the system:
// the sidecar was answering 200 to a direct curl the whole time, and the failure was here.
console.error(`[gitea] frame ${method} ${subpath} -> ${sidecar} failed:`, err);
return ctx.text('gitea unreachable', 502);
}
});
// Last, so everything above wins.
router_.route('/', proxy.router);
export const router = router_;
/** Base URL of the sidecar's HTTP server, or null if it has not reported in yet. */
export const getGiteaServerUrl = proxy.getHttpUrl;
// ── The cookie ──
const COOKIE = 'officer_gitea_frame';
const TTL_MS = 12 * 60 * 60 * 1000;
/** token → { userId, expires }. In memory on purpose: a restart closing every frame is correct. */
const sessions = new Map<string, { userId: number; expires: number }>();
const sweep = () => {
const now = Date.now();
for (const [token, s] of sessions) if (s.expires < now) sessions.delete(token);
};
/** Mint a frame token for an authenticated caller, and the Set-Cookie that carries it. */
export function mintFrameCookie(userId: number, prefix: string): string {
sweep();
const token = randomBytes(32).toString('base64url');
sessions.set(token, { userId, expires: Date.now() + TTL_MS });
// Path-scoped to the frame, so it is never sent to the rest of the platform. `Lax` rather than `None`
// because the frame is same-origin — which is the entire reason this is proxied instead of pointed at
// gitea.officer.dev, where the cookie would be third-party and dropped.
return `${COOKIE}=${token}; Path=${prefix}/_web; Max-Age=${Math.floor(TTL_MS / 1000)}; HttpOnly; SameSite=Lax`;
}
/** The user this request's frame cookie belongs to, or null. */
export function frameUser(cookieHeader: string | null): number | null {
if (!cookieHeader) return null;
const raw = cookieHeader
.split(';')
.map((p) => p.trim())
.find((p) => p.startsWith(`${COOKIE}=`));
if (!raw) return null;
const found = sessions.get(raw.slice(COOKIE.length + 1));
if (!found) return null;
if (found.expires < Date.now()) {
sessions.delete(raw.slice(COOKIE.length + 1));
return null;
}
return found.userId;
}