Eleventh plugin out, and the first that is not a move. The seventeen files that reimplemented a fraction of Gitea's interface were deleted rather than carried across — each one a promise to keep pace with a project that moves faster than this plugin will. That removed a platform change nobody now has to build. useGiteaLocation read :owner/:name/:tab/:item from five route patterns and a plugin gets two, so extracting as-is would have needed the plugin system to declare extra routes. Nothing inside an iframe needs a route, so the requirement went with the screen. No regression window: gitea's router had been commented out of hono.ts since 2026-08-13 with api: [], and officer-gitea was running nowhere. The screen rendered and every request 404'd. The proxy is what makes the iframe possible, not decoration. Officer and Gitea are different registrable domains, so a frame pointed straight at the instance gets a login page whose cookie the browser drops as third-party. Through Officer's origin it is first-party and survives; X-Frame-Options and CSP are stripped, safe only because the frame is the origin the browser already authenticated. /_web/* forwards the browser's own cookies and injects NOTHING. The sidecar holds an API token and injecting it there would show every user the owner's view of every repository. It is used only by /_api/*. Two doors, because an iframe's sub-resources carry no Authorization header: the ordinary routes go through the platform's gate, and /_web/* is declared unauthenticated and authenticates itself with a minted, path-scoped cookie — 32 random bytes against the user who asked, not dev-proxy's constant =1 which anyone knowing the cookie name can set. Verifying it also sets `user`, because createSidecarProxy fills X-Officer-User from it and the sidecar refuses a request without one. Ships an affordance: git for what git does, tea for pull requests and issues. Nothing copies it into capabilities/ yet — gitea is that mechanism's first customer.
111 lines
4.9 KiB
TypeScript
111 lines
4.9 KiB
TypeScript
import { randomBytes } from 'node:crypto';
|
|
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 });
|
|
|
|
/**
|
|
* Mint the frame cookie. Registered BEFORE the proxy's `/*`, because hono matches in registration order
|
|
* and the wildcard would otherwise swallow it and forward `/session` to the sidecar, which has no such
|
|
* route and would answer 404 — a 404 that looks exactly like the plugin being broken.
|
|
*/
|
|
proxy.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: verify the cookie, and turn it back into a user.
|
|
*
|
|
* `use` rather than `all`, so it runs ahead of the proxy's `/*` regardless of registration order.
|
|
*
|
|
* Setting `user` is not a convenience — `createSidecarProxy` reads `ctx.get('user').id` to fill
|
|
* `X-Officer-User`, and the sidecar refuses a request without it. On this path the platform's gate never
|
|
* ran, so nothing has set it. The cookie IS the authentication here, and this is where it resolves to
|
|
* the account it was minted for.
|
|
*/
|
|
proxy.router.use('/_web/*', async (ctx, next) => {
|
|
const userId = frameUser(ctx.req.header('cookie') ?? null);
|
|
if (userId === null) {
|
|
// Deliberately terse. Whoever reaches this without a cookie is a stray request, not a person — the
|
|
// panel mints one before the frame loads.
|
|
return ctx.text('no gitea frame session', 401);
|
|
}
|
|
ctx.set('user', { id: userId } as never);
|
|
return next();
|
|
});
|
|
|
|
export const router = proxy.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;
|
|
}
|