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:
2026-04-23 11:47:02 -06:00
parent 4ccc433eb9
commit aba935c8ac
3 changed files with 51 additions and 24 deletions

View File

@@ -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<string> {
// 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<T>(
path: string,
body?: Record<string, unknown>,
): Promise<T> {
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<void> {
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<boolean> {
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;

View File

@@ -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 {