/api/v1/version is not reliably public — an instance with REQUIRE_SIGNIN_VIEW answers 403 — so naming a perfectly good URL was refused with "the server answered 403" and whoever typed it had nothing to act on. /api/healthz is Gitea's own health endpoint and answers before any authentication, which is the whole point of it. Tried first, with version as the fallback. A 401 or 403 from either now counts as reachable. This step only asks whether there is a Gitea at the address; 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.
185 lines
9.7 KiB
TypeScript
185 lines
9.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> {
|
|
// `/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<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) };
|
|
}
|
|
}
|