`.text()` decompresses, so the rewritten HTML goes back plain — while the headers still said `content-encoding: gzip`. The next hop believed the header, tried to gunzip plain text, and failed with ZlibError. Which surfaced as "gitea unreachable": a 502 naming nothing, while a direct curl to the sidecar returned 200 the whole time.
398 lines
19 KiB
TypeScript
398 lines
19 KiB
TypeScript
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,
|
|
UnreadableCredentials,
|
|
} 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<boolean> {
|
|
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<Response> {
|
|
// Asked first, and only for its failure: a row whose token cannot be decrypted must NOT be reported as
|
|
// configured. It would send the panel to the frame, which then throws on every request — which is
|
|
// exactly what happened, and it reads as the sidecar being broken rather than as a token needing
|
|
// re-entering.
|
|
try {
|
|
await getGiteaConfig(userId);
|
|
} catch (err) {
|
|
if (err instanceof UnreadableCredentials) {
|
|
return Response.json({
|
|
configured: false,
|
|
unreadable: true,
|
|
isOwner: await isOwner(userId),
|
|
error: err.message,
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
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<Response> {
|
|
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);
|
|
// BOTH of these, and the second is not optional. `.text()` has already decompressed, so the
|
|
// body being returned is plain — while `out` still carries Gitea's `content-encoding: gzip`.
|
|
// The next hop believes that header, tries to gunzip plain text, and fails with ZlibError:
|
|
// a 502 that names nothing and looks exactly like the sidecar being unreachable.
|
|
out.delete('content-length'); // the body just changed length
|
|
out.delete('content-encoding'); // and is no longer compressed
|
|
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));
|
|
// Named, because the remedy is specific and the generic message sends people to the logs. This one
|
|
// is not a fault: a token was encrypted under a key that no longer exists.
|
|
if (err instanceof UnreadableCredentials) {
|
|
return Response.json({ error: err.message, unreadable: true }, { status: 409 });
|
|
}
|
|
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<string, unknown>).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'));
|