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(); 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. /** * Thrown when the stored token exists but cannot be decrypted. * * Not a hypothetical. The platform's encryption key is minted on first use and NOT backed up, so losing * the key file re-mints it and every previously encrypted column becomes unreadable — which happened on * this project's own server, taking slskd, memos and gitea credentials with it. AES-GCM reports it as * "Unsupported state or unable to authenticate data", a message that says nothing to whoever sees it. * * Distinguished from "not configured" because the remedy differs: nothing is broken, a token simply has * to be pasted again. */ export class UnreadableCredentials extends Error { constructor() { super('stored gitea credentials cannot be decrypted — the token needs entering again'); this.name = 'UnreadableCredentials'; } } export async function getGiteaConfig(userId: number): Promise { if (cache.has(userId)) return cache.get(userId) ?? null; let row: Awaited>; try { row = await getResolvedServiceCredentials(userId, 'gitea'); } catch (err) { // Only the decrypt failure is translated. Anything else — a database that is down, a bug — must keep // its own error, or a real fault would be reported to the owner as "re-enter your token". if (err instanceof Error && /unable to authenticate data|unsupported state/i.test(err.message)) { throw new UnreadableCredentials(); } throw err; } 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 => 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 { const headers = { ...authHeaders(config), ...((init?.headers as Record) ?? {}) }; 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. */ /** * Is there a Gitea at this URL at all — no token involved. * * Naming the instance is its own step: the owner says where Gitea is, and only then does anyone ask for * a token for it. Probing with a token that does not exist yet would refuse a perfectly good URL for the * wrong reason, and send whoever typed it looking for a problem with their credentials. * * `/api/v1/version` answers unauthenticated on every Gitea, so a 200 means "this is a Gitea and it is * up", which is exactly what this step needs to establish. */ export async function reachable(base: string): Promise { // `/api/healthz` FIRST. It is Gitea's own health endpoint and it is public by design — it answers // before any authentication, which is the whole point of it. // // `/api/v1/version` was tried first and is NOT reliably public: an instance with // `REQUIRE_SIGNIN_VIEW` answers 403, so a perfectly good URL was refused with "the server answered // 403" and whoever typed it had no idea what to do about it. // // A 401 or 403 from EITHER endpoint still proves something answered at that address. This step only // asks "is there a Gitea here"; whether the caller may read it is the token step's question, and // failing here for that reason sends people looking for the wrong problem. for (const path of ['/api/healthz', '/api/v1/version']) { try { const res = await fetch(`${base}${path}`, { signal: AbortSignal.timeout(8000) }); if (res.ok) { const body = (await res.json().catch(() => null)) as { version?: string } | null; return { ok: true, version: body?.version ?? null, user: null }; } if (res.status === 401 || res.status === 403) return { ok: true, version: null, user: null }; // Anything else — 404, 502, a login page — is worth trying the other path for before giving up. } catch { // Network-level. Try the other path; if both fail the error below is the honest one. } } return { ok: false, version: null, user: null, error: 'no Gitea answered at that URL — check it is reachable from the server, not just from your browser', }; } export async function probe(config: GiteaConfig): Promise { 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) }; } }