Files
gitea/api/router.ts
T
officer 21cb386e3d stop asking for compression, so nothing has to unpick it
Every asset in the frame answered 502. `fetch()` decompresses transparently
while `upstream.headers` still claims content-encoding: gzip, so re-emitting
those headers with the decompressed body hands the next hop a lie and it dies
with ZlibError.

The HTML escaped it only because the sidecar deletes that header when it
rewrites — which is why the page rendered and every stylesheet, script and
image beside it failed.

Not asking for compression removes the mismatch rather than chasing it through
two hops. The cost is nil: this hop is loopback, and the browser's own
connection is compressed by whatever terminates TLS.
2026-08-19 09:16:54 +00:00

169 lines
7.9 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);
}
// ACCEPT-ENCODING GOES TOO, and this one is not tidiness.
//
// `fetch()` transparently decompresses a response, but `upstream.headers` still says
// `content-encoding: gzip`. Re-emitting those headers with the decompressed body hands the browser a
// lie, and it fails with ZlibError — reported here as a 502, which is what every asset in the frame
// was doing while the HTML rendered fine. The HTML escaped only because the sidecar drops that header
// when it rewrites.
//
// Asking for no compression at all removes the mismatch instead of chasing it through two hops. The
// cost is nil: this hop is loopback, and the browser's own connection to the platform is compressed by
// whatever terminates TLS.
headers.delete('accept-encoding');
// 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;
}