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.
115 lines
6.3 KiB
TypeScript
115 lines
6.3 KiB
TypeScript
import { getResolvedServiceCredentials } from 'officerdb';
|
|
|
|
// Where the Gitea instance is and what it takes to talk to it. The credential lives here and only here —
|
|
// the platform forwards to this sidecar and holds nothing.
|
|
//
|
|
// Gitea authenticates API calls with a personal access token minted from the instance's own
|
|
// Settings → Applications → Access Tokens. That is the OWNER'S to create and paste in; there is no way to
|
|
// derive one from a platform session, and no reason to want one.
|
|
//
|
|
// THE HEADER IS `token `, NOT `Bearer `. Gitea's own scheme, confirmed against a live 1.27.0 instance's
|
|
// swagger: `AuthorizationHeaderToken` is documented as "API tokens must be prepended with \"token\"
|
|
// followed by a space." Recent Gitea also tolerates `Bearer`, but `token` is the documented spelling and
|
|
// the one that works on every version we might be pointed at. The query-string forms (`?access_token=` /
|
|
// `?token=`) are deprecated for removal and must not be used — they leak the credential into access logs.
|
|
|
|
export type GiteaConfig = { id: number; base: string; token: string | null };
|
|
|
|
/** Trailing slash stripped, so `${base}/api/v1/...` never doubles the separator. */
|
|
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
|
|
|
|
const cache = new Map<number, GiteaConfig | null>();
|
|
|
|
export function invalidateGiteaConfig(userId: number): void {
|
|
cache.delete(userId);
|
|
}
|
|
|
|
/**
|
|
* Clear every user's cached config.
|
|
*
|
|
* Needed because a member's entry holds the OWNER'S base, resolved at read time. Dropping only the
|
|
* caller's entry was correct while each row was self-contained; once rows inherit, changing the instance
|
|
* leaves every member cached against the previous host until this process restarts — pointing their
|
|
* tokens at a server the owner has just moved away from. Call this whenever the owner's row changes.
|
|
*/
|
|
export function invalidateAllGiteaConfigs(): void {
|
|
cache.clear();
|
|
}
|
|
|
|
// One Gitea instance, one token per person. The base comes from whoever owns the platform; the token is
|
|
// this caller's own, so what they see is their account — their repos, their notifications, their issues.
|
|
//
|
|
// getResolvedServiceCredentials does the inheriting: the owner's row carries the URL, everyone else's
|
|
// carries only a token and resolves the base from the owner's. It returns null when the pairing is not
|
|
// usable — no row for this user, or a row waiting on an instance the owner has not connected — and both
|
|
// cases mean the same thing to every caller here: Gitea is not available to you yet.
|
|
//
|
|
// A member's URL is never read from their own row because it is never written there. That is what stops
|
|
// a settings form from becoming a per-user SSRF hop.
|
|
export async function getGiteaConfig(userId: number): Promise<GiteaConfig | null> {
|
|
if (cache.has(userId)) return cache.get(userId) ?? null;
|
|
const row = await getResolvedServiceCredentials(userId, 'gitea');
|
|
const config = row ? { id: row.id, base: normalizeBase(row.url), token: row.secret } : null;
|
|
cache.set(userId, config);
|
|
return config;
|
|
}
|
|
|
|
export const authHeaders = (config: GiteaConfig): Record<string, string> =>
|
|
config.token ? { Authorization: `token ${config.token}` } : {};
|
|
|
|
/**
|
|
* One call against the instance. Returns the raw Response so callers can stream or inspect status —
|
|
* nothing here parses a repository or an issue, because that is the owner's code and this layer has no
|
|
* business reading it.
|
|
*/
|
|
export async function callGitea(config: GiteaConfig, path: string, init?: RequestInit): Promise<Response> {
|
|
const headers = { ...authHeaders(config), ...((init?.headers as Record<string, string>) ?? {}) };
|
|
return fetch(`${config.base}${path}`, { ...init, headers, signal: init?.signal ?? AbortSignal.timeout(20_000) });
|
|
}
|
|
|
|
export type ProbeResult = { ok: boolean; version: string | null; user: string | null; error?: string };
|
|
|
|
/**
|
|
* Is the instance up, and does the stored token actually work?
|
|
*
|
|
* Both calls carry the token, which is the one place this deliberately departs from the memos sidecar's
|
|
* shape. Memos probes `/healthz` unauthenticated first so a bad URL is distinguishable from a bad token.
|
|
* Gitea has no such endpoint under a common configuration: with `REQUIRE_SIGNIN_VIEW` enabled — which the
|
|
* instance this was built against has — even `/api/v1/version` answers 403 with "Only signed in user is
|
|
* allowed to call APIs". An unauthenticated pre-flight would therefore report "unreachable" for a
|
|
* perfectly healthy server, which is the most misleading answer available.
|
|
*
|
|
* So the split is by failure mode instead, which is what the UI actually needs to tell apart:
|
|
* throw (DNS, refused, TLS, timeout) → unreachable, the URL is wrong
|
|
* 401/403 → reached it fine, the token is wrong
|
|
* 200 → both good
|
|
* Verified against the live instance: unauthenticated /version → 403, /version with a junk token → 401.
|
|
*/
|
|
export async function probe(config: GiteaConfig): Promise<ProbeResult> {
|
|
if (!config.token) return { ok: false, version: null, user: null, error: 'no access token stored' };
|
|
|
|
let version: string | null = null;
|
|
try {
|
|
const res = await callGitea(config, '/api/v1/version', { signal: AbortSignal.timeout(8000) });
|
|
if (res.status === 401 || res.status === 403) {
|
|
return { ok: false, version: null, user: null, error: `token rejected (${res.status})` };
|
|
}
|
|
if (!res.ok) return { ok: false, version: null, user: null, error: `instance returned ${res.status}` };
|
|
const body = (await res.json().catch(() => ({}))) as { version?: string };
|
|
version = body.version ?? null;
|
|
} catch (err) {
|
|
return { ok: false, version: null, user: null, error: `unreachable: ${String(err)}` };
|
|
}
|
|
|
|
// `/version` is satisfied by any valid token; `/user` is what proves the token is bound to an actual
|
|
// account and yields the identity the connection screen shows back to the owner.
|
|
try {
|
|
const me = await callGitea(config, '/api/v1/user');
|
|
if (!me.ok) return { ok: false, version, user: null, error: `token rejected (${me.status})` };
|
|
const body = (await me.json().catch(() => ({}))) as { login?: string; full_name?: string; username?: string };
|
|
return { ok: true, version, user: body.login || body.username || body.full_name || null };
|
|
} catch (err) {
|
|
return { ok: false, version, user: null, error: String(err) };
|
|
}
|
|
}
|