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 {