fix(gitea): healthz probe + DB-first token + honest banner copy

Three related bugs surfaced by the Gitea Setup wizard:

1. checkStatus probed /api/v1/version unauthenticated, which returns 403
   under REQUIRE_SIGNIN_VIEW=true (Gitea's default). Result: giteaOnline
   and installComplete always read false on any REQUIRE_SIGNIN_VIEW=true
   install, producing a green "Setup Complete" banner over two red-X
   foundational rows.

   Fix: use /api/healthz (public, exempt from sign-in requirement).
   Bonus: split installComplete from giteaOnline — online now honestly
   means "process responding", installComplete means "admin user exists"
   (verified either by DB flag or by Basic Auth probe on /user). Status
   code is now logged on healthz failure for debuggability.

2. docs-history.service.ts read the API token from env only, bypassing
   the DB. After the setup wizard wrote the token to siteSettings,
   docs-history still saw env.GITEA_API_TOKEN empty and silently did
   nothing. Same file also had a second /api/v1/version bug in
   isAvailable().

   Fix: route token lookup through giteaClient.getConfig() (DB-first,
   env fallback — same pattern as the rest of the codebase). Switch
   isAvailable() to /api/healthz.

3. UI banner confidently claimed "Gitea is not running" on any
   non-200 response, including the 403 case above. Misleading, and
   dangerous to point users at `docker compose up -d gitea` when the
   container is already running.

   Fix: softer "not reachable" copy that points users at the API log
   for the actual status code.

Bunker Admin
This commit is contained in:
bunker-admin 2026-04-23 11:47:02 -06:00
parent 4ccc433eb9
commit aba935c8ac
3 changed files with 51 additions and 24 deletions

View File

@ -228,8 +228,8 @@ export default function GiteaSetupPage() {
<Alert <Alert
type="warning" type="warning"
showIcon showIcon
message="Gitea is not running" message="Gitea is not reachable"
description="Start the Gitea container before proceeding. Run: docker compose up -d gitea" description="The admin API couldn't reach Gitea at its configured URL. If the container is already running, check API logs for the exact response (status code is logged at debug level). Otherwise start it with: docker compose up -d gitea"
/> />
)} )}

View File

@ -1,5 +1,6 @@
import { env } from '../../config/env'; import { env } from '../../config/env';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { giteaClient } from '../../services/gitea.client';
/** /**
* Docs version history via Gitea API. * Docs version history via Gitea API.
@ -39,8 +40,11 @@ function getBaseUrl(): string {
return env.GITEA_URL; return env.GITEA_URL;
} }
function getApiToken(): string { async function getApiToken(): Promise<string> {
return env.GITEA_API_TOKEN || ''; // DB-first (token is stored encrypted in siteSettings after the setup wizard runs),
// env var is only a bootstrap fallback for environments that set it manually.
const config = await giteaClient.getConfig();
return config.apiToken || '';
} }
function getRepoPath(): string { function getRepoPath(): string {
@ -65,7 +69,7 @@ async function giteaRequest<T>(
path: string, path: string,
body?: Record<string, unknown>, body?: Record<string, unknown>,
): Promise<T> { ): Promise<T> {
const token = getApiToken(); const token = await getApiToken();
if (!token) throw new Error('Gitea API token not configured'); if (!token) throw new Error('Gitea API token not configured');
const url = `${getBaseUrl()}/api/v1${path}`; const url = `${getBaseUrl()}/api/v1${path}`;
@ -123,7 +127,7 @@ async function commitFile(
authorName: string, authorName: string,
authorEmail: string, authorEmail: string,
): Promise<void> { ): Promise<void> {
const token = getApiToken(); const token = await getApiToken();
if (!token) return; // Silently skip if not configured if (!token) return; // Silently skip if not configured
const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`; const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`;
@ -239,14 +243,15 @@ async function restoreRevision(
* Check if the Gitea history feature is available (token configured and Gitea reachable). * Check if the Gitea history feature is available (token configured and Gitea reachable).
*/ */
async function isAvailable(): Promise<boolean> { async function isAvailable(): Promise<boolean> {
const token = getApiToken(); const token = await getApiToken();
if (!token) return false; if (!token) return false;
try { try {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); const timeout = setTimeout(() => controller.abort(), 5000);
try { try {
const res = await fetch(`${getBaseUrl()}/api/v1/version`, { // Use /api/healthz — public endpoint, exempt from REQUIRE_SIGNIN_VIEW.
const res = await fetch(`${getBaseUrl()}/api/healthz`, {
signal: controller.signal, signal: controller.signal,
}); });
return res.ok; return res.ok;

View File

@ -116,25 +116,30 @@ async function checkStatus(): Promise<{
let reposCreated = false; let reposCreated = false;
let oauthConfigured = false; let oauthConfigured = false;
// Check Gitea online // Check Gitea is reachable via the public healthz endpoint.
// `/api/healthz` is exempt from REQUIRE_SIGNIN_VIEW, which gates every other /api/v1/* route.
// Using an auth-gated endpoint here would produce a false-negative 403 on a perfectly healthy Gitea.
try { try {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); const timeout = setTimeout(() => controller.abort(), 5000);
try { try {
const res = await fetch(`${env.GITEA_URL}/api/v1/version`, { const res = await fetch(`${env.GITEA_URL}/api/healthz`, {
signal: controller.signal, signal: controller.signal,
redirect: 'manual', redirect: 'manual',
}); });
giteaOnline = res.ok; giteaOnline = res.ok;
installComplete = res.ok; // If API responds, install is complete if (!res.ok) {
logger.debug(`Gitea /api/healthz returned ${res.status} ${res.statusText}`);
}
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
} }
} catch { } catch (err) {
// Not reachable logger.debug(`Gitea /api/healthz unreachable: ${err instanceof Error ? err.message : String(err)}`);
} }
// Check DB settings // Check DB settings
let dbSetupComplete = false;
try { try {
const settings = await prisma.siteSettings.findFirst({ const settings = await prisma.siteSettings.findFirst({
select: { select: {
@ -148,22 +153,39 @@ async function checkStatus(): Promise<{
if (settings) { if (settings) {
tokenConfigured = !!settings.giteaApiToken; tokenConfigured = !!settings.giteaApiToken;
oauthConfigured = !!settings.giteaOauthClientId && !!settings.giteaOauthClientSecret; oauthConfigured = !!settings.giteaOauthClientId && !!settings.giteaOauthClientSecret;
dbSetupComplete = !!settings.giteaSetupComplete;
if (settings.giteaSetupComplete) {
return {
giteaOnline,
installComplete,
tokenConfigured,
reposCreated: true, // Trust the flag
oauthConfigured,
setupComplete: true,
};
}
} }
} catch { } catch {
// DB not available // DB not available
} }
// `installComplete` here means: Gitea's install wizard is done AND an admin user exists.
// `giteaOnline` alone (healthz 200) proves the process is up but says nothing about admin bootstrap.
// Preference order:
// 1. If DB flag is set, trust it (fast path — no network call)
// 2. Else if Gitea is online and we have an admin password, probe /user with Basic Auth
if (dbSetupComplete) {
installComplete = true;
} else if (giteaOnline && env.GITEA_ADMIN_PASSWORD) {
try {
await giteaBasicRequest<{ login: string }>('GET', '/user', 'admin', env.GITEA_ADMIN_PASSWORD);
installComplete = true;
} catch {
// Admin user doesn't exist or password mismatch — installComplete stays false
}
}
if (dbSetupComplete) {
return {
giteaOnline,
installComplete,
tokenConfigured,
reposCreated: true, // Trust the flag
oauthConfigured,
setupComplete: true,
};
}
// Check repos if we have a token // Check repos if we have a token
if (tokenConfigured && giteaOnline) { if (tokenConfigured && giteaOnline) {
try { try {