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 { // 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( method: string, path: string, body?: Record, ): Promise { 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 = { 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 { try { const data = await giteaRequest( '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 { 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 { const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`; try { return await giteaRequest( '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 { const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`; try { const data = await giteaRequest( '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 { 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 { 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, };