the config cache is keyed by user, but a member's entry holds the owner's base — resolved at read time, since a member's row stores no url. dropping only the caller's entry was correct while each row was self-contained; once rows inherit, the owner moving or disconnecting the instance left every member cached against the previous host until the sidecar restarted. also returns the resolved instanceUrl from connectionState. members not being able to set it is the invariant; not seeing it never was and could not be — every avatar_url the instance hands back is on that origin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) };
|
|
}
|
|
}
|