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
273 lines
7.7 KiB
TypeScript
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,
|
|
};
|