The form asked for a URL and a token together, on a fresh instance, while the machine in front of it often had both on disk already — somebody had run `tea login` by hand. Two changes. One thing at a time: until the platform knows where Gitea is, a token cannot be checked, because there is nothing to check it against. So the owner names the instance first, and only then does the page ask for — or discover — a token. That step now probes /api/v1/version unauthenticated, since demanding a token to validate a URL refuses a good URL for the wrong reason and sends whoever typed it looking for a credential problem. And it reads tea's own config when the platform has no instance yet, offering the URL it finds. A machine that already has a working login should not be asked to retype what it can see. Members skip the first step entirely — the instance is the owner's.
164 lines
8.7 KiB
TypeScript
164 lines
8.7 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.
|
|
/**
|
|
* 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<GiteaConfig | null> {
|
|
if (cache.has(userId)) return cache.get(userId) ?? null;
|
|
let row: Awaited<ReturnType<typeof getResolvedServiceCredentials>>;
|
|
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<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.
|
|
*/
|
|
/**
|
|
* 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<ProbeResult> {
|
|
try {
|
|
const res = await fetch(`${base}/api/v1/version`, { signal: AbortSignal.timeout(8000) });
|
|
if (!res.ok) return { ok: false, version: null, user: null, error: `the server answered ${res.status}` };
|
|
const body = (await res.json().catch(() => null)) as { version?: string } | null;
|
|
return { ok: true, version: body?.version ?? null, user: null };
|
|
} catch {
|
|
return { ok: false, version: null, user: null, error: 'could not reach that URL from the server' };
|
|
}
|
|
}
|
|
|
|
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) };
|
|
}
|
|
}
|