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:
parent
4ccc433eb9
commit
aba935c8ac
@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user