changemaker.lite/api/src/modules/docs/docs-history.service.ts
bunker-admin aba935c8ac 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
2026-04-23 11:47:02 -06:00

273 lines
7.7 KiB
TypeScript

import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { giteaClient } from '../../services/gitea.client';
/**
* Docs version history via Gitea API.
* Auto-commits file saves and provides history/restore.
*
* Uses the main project repo (not the docs-comments repo).
* Files are stored at mkdocs/docs/{path} in the repo.
*/
const GITEA_TIMEOUT = 15000;
/**
* Encode a file path for Gitea API.
* Gitea expects each path segment to be individually encoded.
*/
function encodeRepoPath(filePath: string): string {
return filePath.split('/').map(encodeURIComponent).join('/');
}
interface GiteaCommit {
sha: string;
commit: {
message: string;
author: { name: string; email: string; date: string };
committer: { name: string; email: string; date: string };
};
}
interface GiteaFileContent {
content: string; // base64 encoded
sha: string;
name: string;
path: string;
}
function getBaseUrl(): string {
return env.GITEA_URL;
}
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 {
// The main project repo — matches repo_url in mkdocs.yml
return env.GITEA_DOCS_REPO || 'admin/changemaker.lite';
}
function getDocsPrefix(): string {
// Relative path within repo where docs live
return env.GITEA_DOCS_PREFIX || 'mkdocs/docs';
}
function getRepoBranch(): string {
return env.GITEA_DOCS_BRANCH || 'v2';
}
/**
* Make an authenticated Gitea API request.
*/
async function giteaRequest<T>(
method: string,
path: string,
body?: Record<string, unknown>,
): Promise<T> {
const token = await getApiToken();
if (!token) throw new Error('Gitea API token not configured');
const url = `${getBaseUrl()}/api/v1${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), GITEA_TIMEOUT);
const headers: Record<string, string> = {
Authorization: `token ${token}`,
};
let fetchBody: string | undefined;
if (body) {
headers['Content-Type'] = 'application/json';
fetchBody = JSON.stringify(body);
}
try {
const res = await fetch(url, { method, headers, body: fetchBody, signal: controller.signal });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Gitea ${method} ${path}: ${res.status} ${text}`);
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
return {} as T;
} finally {
clearTimeout(timeout);
}
}
/**
* Get the current SHA of a file in the repo (needed for updates).
*/
async function getFileSha(repoFilePath: string): Promise<string | null> {
try {
const data = await giteaRequest<GiteaFileContent>(
'GET',
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}?ref=${getRepoBranch()}`,
);
return data.sha;
} catch {
return null;
}
}
/**
* Commit a file change to Gitea.
* Fire-and-forget — errors are logged but don't propagate.
*/
async function commitFile(
docsRelativePath: string,
content: string,
authorName: string,
authorEmail: string,
): Promise<void> {
const token = await getApiToken();
if (!token) return; // Silently skip if not configured
const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`;
try {
const existingSha = await getFileSha(repoFilePath);
const base64Content = Buffer.from(content, 'utf-8').toString('base64');
if (existingSha) {
// Update existing file
await giteaRequest(
'PUT',
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}`,
{
message: `Update ${docsRelativePath} by ${authorName}`,
content: base64Content,
sha: existingSha,
branch: getRepoBranch(),
author: { name: authorName, email: authorEmail },
},
);
} else {
// Create new file
await giteaRequest(
'POST',
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}`,
{
message: `Create ${docsRelativePath} by ${authorName}`,
content: base64Content,
branch: getRepoBranch(),
author: { name: authorName, email: authorEmail },
},
);
}
logger.debug(`Docs history: committed ${docsRelativePath} by ${authorName}`);
} catch (err) {
logger.warn(`Docs history: failed to commit ${docsRelativePath}:`, err instanceof Error ? err.message : err);
}
}
/**
* Get commit history for a documentation file.
*/
async function getFileHistory(docsRelativePath: string, limit = 20): Promise<GiteaCommit[]> {
const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`;
try {
return await giteaRequest<GiteaCommit[]>(
'GET',
`/repos/${getRepoPath()}/commits?sha=${getRepoBranch()}&path=${encodeURIComponent(repoFilePath)}&limit=${limit}`,
);
} catch (err) {
logger.warn(`Docs history: failed to get history for ${docsRelativePath}:`, err instanceof Error ? err.message : err);
return [];
}
}
/**
* Get file content at a specific commit.
*/
async function getFileAtCommit(docsRelativePath: string, commitSha: string): Promise<string | null> {
const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`;
try {
const data = await giteaRequest<GiteaFileContent>(
'GET',
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}?ref=${commitSha}`,
);
return Buffer.from(data.content, 'base64').toString('utf-8');
} catch (err) {
logger.warn(`Docs history: failed to get file at commit ${commitSha}:`, err instanceof Error ? err.message : err);
return null;
}
}
/**
* Restore a file to a previous version by reading old content and creating a new commit.
*/
async function restoreRevision(
docsRelativePath: string,
commitSha: string,
authorName: string,
authorEmail: string,
): Promise<string | null> {
const oldContent = await getFileAtCommit(docsRelativePath, commitSha);
if (!oldContent) return null;
const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`;
const currentSha = await getFileSha(repoFilePath);
if (!currentSha) return null;
try {
await giteaRequest(
'PUT',
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}`,
{
message: `Restore ${docsRelativePath} to revision ${commitSha.substring(0, 7)} by ${authorName}`,
content: Buffer.from(oldContent, 'utf-8').toString('base64'),
sha: currentSha,
branch: getRepoBranch(),
author: { name: authorName, email: authorEmail },
},
);
return oldContent;
} catch (err) {
logger.warn(`Docs history: failed to restore ${docsRelativePath}:`, err instanceof Error ? err.message : err);
return null;
}
}
/**
* Check if the Gitea history feature is available (token configured and Gitea reachable).
*/
async function isAvailable(): Promise<boolean> {
const token = await getApiToken();
if (!token) return false;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
// Use /api/healthz — public endpoint, exempt from REQUIRE_SIGNIN_VIEW.
const res = await fetch(`${getBaseUrl()}/api/healthz`, {
signal: controller.signal,
});
return res.ok;
} finally {
clearTimeout(timeout);
}
} catch {
return false;
}
}
export const docsHistoryService = {
commitFile,
getFileHistory,
getFileAtCommit,
restoreRevision,
isAvailable,
};