import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; import { createSidecarConnector } from '@@/sidecar/connect'; import { rewriteHtml, rewriteLocation, rewriteSetCookie } from './rewrite'; import { getServiceConnection, saveServiceConnection, deleteServiceConnection, recordServiceProbe, getServiceInstanceUrl, getOwnerUser, } from 'officerdb'; import { callGitea, getGiteaConfig, invalidateGiteaConfig, invalidateAllGiteaConfigs, normalizeBase, probe, } from './upstream'; import { API_URL } from '@@/officer-url.mjs'; // The officer-gitea sidecar. Owns the whole Gitea contract: the instance URL and the personal access // token. The platform side is a thin auth-gated forwarder holding no Gitea credentials. // // ───────────────────────────────────────────────────────────────────────────────────────────────── // HTTP CONTRACT — the platform strips its /api/gitea mount prefix before forwarding. // // GET /_health is the instance up, and does the stored token work // GET /_config the connection MINUS the token // PUT/POST /_config { url, token } — validated live before it is stored // DELETE /_config forget the connection // ANY /_api/* forwarded to the instance's own /api/*, token attached // // That is the SINGLE-CONNECTION contract slskd and transmission already serve, matched deliberately so // the UI can use the shared `useServiceConnection` / `useServiceConnectionActions` hooks rather than // growing a fourth copy of them. Which is why PUT is accepted (the shared hook's `save` uses it), why a // save answers `{ connection }`, and why DELETE answers the connection state rather than `{ ok }`. // Gitea genuinely is one-of: nobody runs two. // // `/_api/*` is a deliberate pass-through rather than a hand-written wrapper per endpoint, for the same // reason memos does it: Gitea's REST surface is enormous (its swagger is 850KB) and moves between minor // versions, so re-describing it here would mean a second thing to keep in sync, and the UI is going to // speak the upstream's shapes anyway. The allow-list below is the one piece of policy: it keeps the // sidecar from being a general-purpose SSRF hop into whatever else is on that host. // ───────────────────────────────────────────────────────────────────────────────────────────────── // Everything under /api/v1 the UI legitimately needs. // // `/api/v1/admin/*` is excluded ON PURPOSE and should stay excluded. A Gitea token minted by a site // admin carries the admin API with it — create and delete users, rewrite anyone's repositories, mint // tokens for other accounts. None of that is a thing a notes-and-repos panel needs, and a token pasted // into a connection form should not silently become a remote root shell for the instance. If an admin // screen is ever genuinely wanted it gets its own reviewed entry here, not a widened prefix. const ALLOWED = [ /^\/api\/v1\/version(\/|$|\?)/, /^\/api\/v1\/user(\/|$|\?)/, // Public profile data. Note this prefix also covers `/users/{name}/tokens`, which lists and CREATES // access tokens — a token-minting door if it were reachable. It is not: Gitea requires basic auth there // precisely to stop a token escalating its own scopes, and answers `401 auth required` to the // `Authorization: token …` this sidecar sends. Verified against 1.27.0 on 2026-08-06. That protection is // GITEA's, not ours, so if this ever needs re-checking on an upgrade, that is the endpoint to re-check. /^\/api\/v1\/users(\/|$|\?)/, /^\/api\/v1\/orgs(\/|$|\?)/, /^\/api\/v1\/org(\/|$|\?)/, /^\/api\/v1\/teams(\/|$|\?)/, /^\/api\/v1\/repos(\/|$|\?)/, /^\/api\/v1\/repositories(\/|$|\?)/, /^\/api\/v1\/notifications(\/|$|\?)/, /^\/api\/v1\/issues(\/|$|\?)/, /^\/api\/v1\/packages(\/|$|\?)/, // Instance metadata the UI reads to match the instance's own behaviour rather than guess it — // default branch name, whether the markdown renderer allows raw HTML, which features are enabled. /^\/api\/v1\/settings(\/|$|\?)/, /^\/api\/v1\/topics(\/|$|\?)/, /^\/api\/v1\/licenses(\/|$|\?)/, /^\/api\/v1\/gitignore(\/|$|\?)/, /^\/api\/v1\/label(\/|$|\?)/, // Rendering. Gitea's own web UI renders a README through these, and matching it matters: the // instance resolves relative links, #issue references and @mentions against the repo context, which a // client-side markdown pass cannot do. Sanitisation is the instance's too — see renderMarkdown. /^\/api\/v1\/markdown(\/|$|\?)/, /^\/api\/v1\/markup(\/|$|\?)/, ]; function getFreePort(): number { const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); const p = probeServer.port; probeServer.stop(true); if (p == null) throw new Error('failed to acquire a free port'); return p; } const port = getFreePort(); const bad = (error: string, status = 400) => Response.json({ error }, { status }); /** Whoever owns the platform configures the instance; everyone else supplies only their own token. */ async function isOwner(userId: number): Promise { const owner = await getOwnerUser(); return !!owner && owner.id === userId; } // A member's row has no URL to return — it is genuinely not stored, not merely withheld — so this needs // no per-caller filtering. `instanceConfigured` is what the UI needs instead: it distinguishes "you have // not connected yet" from "there is nothing here to connect to", which are different screens. // // `instanceUrl` is the resolved base, returned to members as well as the owner. Not setting it is the // invariant (a member naming the URL would be a per-user SSRF hop); not SEEING it never was, and could // not be: every avatar_url and html_url the instance hands back is on that origin and gets rendered. // The UI needs it as a value rather than as a thousand strings because this instance's ROOT_URL disagrees // with its public host, so links have to be rebased client-side — see retargetUrls. async function connectionState(userId: number): Promise { const connection = await getServiceConnection(userId, 'gitea'); const instanceUrl = await getServiceInstanceUrl('gitea'); return Response.json({ configured: !!connection, connection, instanceUrl, instanceConfigured: !!instanceUrl, isOwner: await isOwner(userId), }); } async function handleConfig(req: Request, userId: number): Promise { if (req.method === 'GET') return connectionState(userId); if (req.method === 'DELETE') { const owner = await isOwner(userId); await deleteServiceConnection(userId, 'gitea'); // The owner disconnecting removes the instance every member's row inherits, so every cached config // is now wrong — they must all resolve to null rather than keep talking to a server the platform no // longer claims. A member disconnecting only affects themselves. if (owner) invalidateAllGiteaConfigs(); else invalidateGiteaConfig(userId); return connectionState(userId); } if (req.method === 'PUT' || req.method === 'POST') { const body = ((await req.json().catch(() => null)) as { url?: unknown; token?: unknown } | null) ?? {}; // A blank token on a save means "keep the stored one", so the URL can be corrected without the // token being re-pasted. There is no way to read it back out to re-send it. const token = typeof body.token === 'string' && body.token.trim() ? body.token.trim() : null; const owner = await isOwner(userId); // The instance is the owner's; the token is each person's own. Which half of the form you get is // decided here rather than in the UI, because a UI that hides a field is a suggestion and this is a // rule: a member who could name the URL would have a per-user SSRF hop behind a settings form. let base: string; if (owner) { const url = typeof body.url === 'string' ? normalizeBase(body.url) : ''; if (!url) return bad('url is required'); if (!/^https?:\/\//i.test(url)) return bad('url must start with http:// or https://'); base = url; } else { // Rejected, not ignored. Ignoring it would let a member believe they had pointed Gitea somewhere // and leave them debugging a screen that is quietly talking to a different server. if (body.url !== undefined) return bad('only the server owner can set the Gitea instance URL', 403); const instance = await getServiceInstanceUrl('gitea'); if (!instance) return bad('the server owner has not connected a Gitea instance yet', 409); base = instance; } const existing = await getServiceConnection(userId, 'gitea'); if (!token && !existing?.hasSecret) return bad('an access token is required the first time'); // Probe with the credentials as they WILL be, so a save either stores something that works or // fails with the reason. Storing first and discovering later makes every downstream screen fail // mysteriously instead. For a member this also proves their token is valid against the shared // instance, which is the only thing that can be wrong on their side. const current = token ? null : await getGiteaConfig(userId); const result = await probe({ id: existing?.id ?? 0, base, token: token ?? current?.token ?? null }); if (!result.ok) return bad(result.error ?? 'could not reach the instance'); const connection = await saveServiceConnection({ userId, service: 'gitea', // Null for anyone but the owner: their row is a credential, and inherits the instance on read. url: owner ? base : null, secret: token ?? undefined, version: result.version, }); // An owner's save may have moved the instance, and every member's cached base was resolved from it. // Clearing only the caller would leave them pointed at the previous host until this process restarts. if (owner) invalidateAllGiteaConfigs(); else invalidateGiteaConfig(userId); return Response.json({ connection, user: result.user, version: result.version }); } return bad('method not allowed', 405); } const server = Bun.serve({ port, hostname: '127.0.0.1', // Release attachments and file uploads through the contents API. maxRequestBodySize: 64 * 1024 * 1024, async fetch(req) { const url = new URL(req.url); const officerUser = req.headers.get('X-Officer-User'); const userId = Number(officerUser); if (!officerUser || !Number.isInteger(userId) || userId <= 0) { return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); } try { if (url.pathname === '/_config') return await handleConfig(req, userId); const config = await getGiteaConfig(userId); if (url.pathname === '/_health') { if (!config) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 }); const started = Date.now(); const result = await probe(config); const ms = Date.now() - started; if (!result.ok) { return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 }); } await recordServiceProbe(userId, 'gitea', result.version); return Response.json({ ok: true, configured: true, user: result.user, version: result.version, ms }); } // ── The web UI, proxied for the iframe ── // // Everything above speaks Gitea's JSON API with the OWNER'S stored token. This does the opposite: // it forwards the browser's own request, untouched, and injects nothing. // // That is deliberate. Gitea's web interface authenticates with its own session cookie, not with an // API token, and the person looking at the iframe signs in to Gitea as themselves. Injecting the // stored token here would silently show every user the OWNER'S view of every repository — the one // failure this whole plugin must not have. // // Same-origin is what makes it work at all: the browser is on the platform's origin, so Gitea's // cookie is first-party and survives. Pointed at gitea.officer.dev directly, an iframe gets a login // page whose cookie the browser discards. if (url.pathname.startsWith('/_web')) { if (!config) return new Response('gitea not connected', { status: 503 }); const subpath = url.pathname.slice('/_web'.length) || '/'; // Hop-by-hop headers and the platform's own auth are dropped; everything else — cookies above // all — goes through verbatim. const headers = new Headers(req.headers); for (const h of ['host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'authorization']) { headers.delete(h); } headers.delete('x-officer-user'); const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; let upstream: Response; try { upstream = await fetch(`${config.base}${subpath}${url.search}`, { method: req.method, headers, body: hasBody ? await req.arrayBuffer() : undefined, // MANUAL: a 302 to /user/login is meaningful to the iframe and must reach it as a redirect, // not be followed here into a page the browser never asked for. redirect: 'manual', signal: AbortSignal.timeout(30_000), }); } catch { return new Response('gitea unreachable', { status: 502 }); } const out = new Headers(upstream.headers); // Gitea refuses to be framed by default, and the whole point here is to frame it. Safe because // the frame is the platform's own origin, which the browser has already authenticated. out.delete('x-frame-options'); out.delete('content-security-policy'); for (const h of ['connection', 'keep-alive', 'transfer-encoding']) out.delete(h); // Where the browser thinks this is. Passed by the plugin's router rather than hardcoded — the // sidecar has no business knowing how the platform routes to it, and `mountPrefix` is the only // thing entitled to decide. const base = req.headers.get('X-Officer-Frame-Base'); const origins = [config.base]; if (base) { const location = out.get('location'); if (location) out.set('location', rewriteLocation(location, base, origins)); // Every Set-Cookie, not just the first: Gitea sets a session and a CSRF cookie together, and // `Headers.get` would concatenate them into one broken value. const cookies = (out as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() ?? []; if (cookies.length) { out.delete('set-cookie'); for (const c of cookies) out.append('set-cookie', rewriteSetCookie(c, base)); } // HTML only. Rewriting JavaScript hits strings that are not URLs and corrupts the file — // `appSubUrl`, patched below by the same pass, is what makes Gitea's own scripts correct. if ((out.get('content-type') ?? '').includes('text/html')) { const body = rewriteHtml(await upstream.text(), base, origins); out.delete('content-length'); // the body just changed length return new Response(body, { status: upstream.status, headers: out }); } } return new Response(upstream.body, { status: upstream.status, headers: out }); } if (url.pathname.startsWith('/_api/')) { if (!config) return Response.json({ error: 'gitea not connected', configured: false }, { status: 503 }); const subpath = url.pathname.slice('/_api'.length) + url.search; if (!ALLOWED.some((re) => re.test(subpath))) return bad('path not allowed', 403); const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; const upstream = await callGitea(config, subpath, { method: req.method, headers: req.headers.get('content-type') ? { 'content-type': req.headers.get('content-type')! } : {}, body: hasBody ? await req.arrayBuffer() : undefined, }); return new Response(upstream.body, { status: upstream.status, headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' }, }); } } catch (err) { // Path only. A repository body is the owner's private code. console.error(`[gitea] ${req.method} ${url.pathname} failed`, String(err)); return Response.json({ error: 'internal error' }, { status: 500 }); } return Response.json({ error: 'not found' }, { status: 404 }); }, }); console.log(`[gitea] listening on 127.0.0.1:${port} (instance configured from the UI, stored in service_connections)`); type ReplyFn = (msg: SidecarEvent) => void; function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { switch (cmd.type) { case 'ping': reply({ type: 'pong', id: cmd.id }); break; default: reply({ type: 'error', id: (cmd as SidecarCommand).id, error: `Unknown command type: ${(cmd as Record).type}`, }); } } const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'gitea', handles: ['gitea'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, onConnected() { connection.send({ type: 'gitea:server', port }); console.log(`[gitea] reported server port ${port} to API`); }, }); function shutdown(signal: string) { console.log(`[gitea] ${signal} received, shutting down...`); try { server.stop(true); } catch { /* already stopped */ } connection.destroy(); process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));