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 {