From aba935c8ac0dd956f50c4d4d29470c143212e197 Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Thu, 23 Apr 2026 11:47:02 -0600 Subject: [PATCH] fix(gitea): healthz probe + DB-first token + honest banner copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin/src/pages/GiteaSetupPage.tsx | 4 +- api/src/modules/docs/docs-history.service.ts | 17 +++--- .../gitea-setup/gitea-setup.service.ts | 54 +++++++++++++------ 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/admin/src/pages/GiteaSetupPage.tsx b/admin/src/pages/GiteaSetupPage.tsx index 4707e6ee..1ae62d53 100644 --- a/admin/src/pages/GiteaSetupPage.tsx +++ b/admin/src/pages/GiteaSetupPage.tsx @@ -228,8 +228,8 @@ export default function GiteaSetupPage() { )} diff --git a/api/src/modules/docs/docs-history.service.ts b/api/src/modules/docs/docs-history.service.ts index 557ea62d..32a3298f 100644 --- a/api/src/modules/docs/docs-history.service.ts +++ b/api/src/modules/docs/docs-history.service.ts @@ -1,5 +1,6 @@ import { env } from '../../config/env'; import { logger } from '../../utils/logger'; +import { giteaClient } from '../../services/gitea.client'; /** * Docs version history via Gitea API. @@ -39,8 +40,11 @@ function getBaseUrl(): string { return env.GITEA_URL; } -function getApiToken(): string { - return env.GITEA_API_TOKEN || ''; +async function getApiToken(): Promise { + // 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 { @@ -65,7 +69,7 @@ async function giteaRequest( path: string, body?: Record, ): Promise { - const token = getApiToken(); + const token = await getApiToken(); if (!token) throw new Error('Gitea API token not configured'); const url = `${getBaseUrl()}/api/v1${path}`; @@ -123,7 +127,7 @@ async function commitFile( authorName: string, authorEmail: string, ): Promise { - const token = getApiToken(); + const token = await getApiToken(); if (!token) return; // Silently skip if not configured const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`; @@ -239,14 +243,15 @@ async function restoreRevision( * Check if the Gitea history feature is available (token configured and Gitea reachable). */ async function isAvailable(): Promise { - const token = getApiToken(); + const token = await getApiToken(); if (!token) return false; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); 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, }); return res.ok; diff --git a/api/src/modules/gitea-setup/gitea-setup.service.ts b/api/src/modules/gitea-setup/gitea-setup.service.ts index 5307d56c..189f437b 100644 --- a/api/src/modules/gitea-setup/gitea-setup.service.ts +++ b/api/src/modules/gitea-setup/gitea-setup.service.ts @@ -116,25 +116,30 @@ async function checkStatus(): Promise<{ let reposCreated = 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 { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { - const res = await fetch(`${env.GITEA_URL}/api/v1/version`, { + const res = await fetch(`${env.GITEA_URL}/api/healthz`, { signal: controller.signal, redirect: 'manual', }); 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 { clearTimeout(timeout); } - } catch { - // Not reachable + } catch (err) { + logger.debug(`Gitea /api/healthz unreachable: ${err instanceof Error ? err.message : String(err)}`); } // Check DB settings + let dbSetupComplete = false; try { const settings = await prisma.siteSettings.findFirst({ select: { @@ -148,22 +153,39 @@ async function checkStatus(): Promise<{ if (settings) { tokenConfigured = !!settings.giteaApiToken; oauthConfigured = !!settings.giteaOauthClientId && !!settings.giteaOauthClientSecret; - - if (settings.giteaSetupComplete) { - return { - giteaOnline, - installComplete, - tokenConfigured, - reposCreated: true, // Trust the flag - oauthConfigured, - setupComplete: true, - }; - } + dbSetupComplete = !!settings.giteaSetupComplete; } } catch { // 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 if (tokenConfigured && giteaOnline) { try {