Add docs CMS: blog authoring, access policies, sharing, version history, templates, metadata, search, Gitea auto-setup

7 documentation system features:
- Blog authoring: frontmatter panel, new post wizard, authors management
- Access policies: per-file/directory edit restrictions with role/user granularity
- Public sharing: share links with collaborative editing via dual JWT auth
- Version history: Gitea auto-commit on save, diff viewer, restore
- Document templates: 8 built-in templates (blog, guide, API ref, ADR, FAQ, etc.)
- Metadata dashboard: overview of all docs with warnings (no-tags, stale, etc.)
- Content search: in-file text search with line-level matches

Gitea auto-setup: one-click configuration of API token, repos, labels, OAuth app
- Backend service + startup hook (auto-configures if GITEA_ADMIN_PASSWORD set)
- Admin GUI wizard at /app/services/gitea/setup
- config.sh now prompts for Gitea admin password

Backend: 10 new files, 5 modified (3 models, 1 enum, 2 migrations, 30+ API endpoints)
Frontend: 13 new files, 3 modified (hooks, components, pages)

Bunker Admin
This commit is contained in:
2026-03-27 13:28:52 -06:00
parent 0fc9ea80bf
commit 8b9ab93856
37 changed files with 6273 additions and 61 deletions

View File

@@ -0,0 +1,76 @@
-- CreateEnum
CREATE TYPE "DocShareLinkStatus" AS ENUM ('ACTIVE', 'REVOKED', 'EXPIRED');
-- AlterEnum
ALTER TYPE "ContactActivityType" ADD VALUE IF NOT EXISTS 'POLL_VOTED';
-- CreateTable
CREATE TABLE "doc_access_policies" (
"id" TEXT NOT NULL,
"document_path" TEXT NOT NULL,
"is_directory" BOOLEAN NOT NULL DEFAULT false,
"allowed_editors" JSONB NOT NULL DEFAULT '[]',
"created_by_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "doc_access_policies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "doc_share_links" (
"id" TEXT NOT NULL,
"document_path" TEXT NOT NULL,
"share_token" TEXT NOT NULL,
"status" "DocShareLinkStatus" NOT NULL DEFAULT 'ACTIVE',
"can_edit" BOOLEAN NOT NULL DEFAULT true,
"expires_at" TIMESTAMP(3),
"max_uses" INTEGER,
"use_count" INTEGER NOT NULL DEFAULT 0,
"guest_name" TEXT,
"created_by_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "doc_share_links_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "doc_watches" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"file_path" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "doc_watches_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "doc_access_policies_document_path_key" ON "doc_access_policies"("document_path");
-- CreateIndex
CREATE INDEX "doc_access_policies_created_by_id_idx" ON "doc_access_policies"("created_by_id");
-- CreateIndex
CREATE UNIQUE INDEX "doc_share_links_share_token_key" ON "doc_share_links"("share_token");
-- CreateIndex
CREATE INDEX "doc_share_links_document_path_idx" ON "doc_share_links"("document_path");
-- CreateIndex
CREATE INDEX "doc_share_links_created_by_id_idx" ON "doc_share_links"("created_by_id");
-- CreateIndex
CREATE INDEX "doc_watches_file_path_idx" ON "doc_watches"("file_path");
-- CreateIndex
CREATE UNIQUE INDEX "doc_watches_user_id_file_path_key" ON "doc_watches"("user_id", "file_path");
-- AddForeignKey
ALTER TABLE "doc_access_policies" ADD CONSTRAINT "doc_access_policies_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "doc_share_links" ADD CONSTRAINT "doc_share_links_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "doc_watches" ADD CONSTRAINT "doc_watches_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "gitea_setup_complete" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -210,6 +210,11 @@ model User {
sharedViewReactions SharedViewReaction[] @relation("SharedViewReactionUser")
calendarExportTokens CalendarExportToken[] @relation("CalendarExportTokenOwner")
// Docs access & sharing
docAccessPoliciesCreated DocAccessPolicy[] @relation("DocAccessPolicyCreator")
docShareLinksCreated DocShareLink[] @relation("DocShareLinkCreator")
docWatches DocWatch[] @relation("DocWatcher")
@@map("users")
}
@@ -975,6 +980,7 @@ model SiteSettings {
giteaCommentsRepoName String @default("docs-comments")
giteaOauthClientId String @default("")
giteaOauthClientSecret String @default("") // Encrypted at rest
giteaSetupComplete Boolean @default(false) @map("gitea_setup_complete")
// Notification settings
notifyAdminShiftSignup Boolean @default(true)
@@ -5169,6 +5175,60 @@ model DocCollabState {
@@map("doc_collab_state")
}
// --- Document Access Policies ---
enum DocShareLinkStatus {
ACTIVE
REVOKED
EXPIRED
}
model DocAccessPolicy {
id String @id @default(cuid())
documentPath String @unique @map("document_path") // e.g. "admin/index.md" or "guides/" (trailing slash = directory)
isDirectory Boolean @default(false) @map("is_directory")
allowedEditors Json @default("[]") @map("allowed_editors") // ["role:CONTENT_ADMIN", "user:clxyz", "all_content_editors"]
createdById String @map("created_by_id")
createdBy User @relation("DocAccessPolicyCreator", fields: [createdById], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([createdById])
@@map("doc_access_policies")
}
model DocShareLink {
id String @id @default(cuid())
documentPath String @map("document_path")
shareToken String @unique @map("share_token")
status DocShareLinkStatus @default(ACTIVE)
canEdit Boolean @default(true) @map("can_edit")
expiresAt DateTime? @map("expires_at")
maxUses Int? @map("max_uses")
useCount Int @default(0) @map("use_count")
guestName String? @map("guest_name")
createdById String @map("created_by_id")
createdBy User @relation("DocShareLinkCreator", fields: [createdById], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([documentPath])
@@index([createdById])
@@map("doc_share_links")
}
model DocWatch {
id String @id @default(cuid())
userId String @map("user_id")
filePath String @map("file_path")
user User @relation("DocWatcher", fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
@@unique([userId, filePath])
@@index([filePath])
@@map("doc_watches")
}
// ============================================================================
// PARTICIPANT NEEDS
// ============================================================================

View File

@@ -191,6 +191,14 @@ const envSchema = z.object({
GITEA_OAUTH_CLIENT_ID: z.string().default(''),
GITEA_OAUTH_CLIENT_SECRET: z.string().default(''),
// Gitea Docs Version History
GITEA_DOCS_REPO: z.string().default('admin/changemaker.lite'),
GITEA_DOCS_PREFIX: z.string().default('mkdocs/docs'),
GITEA_DOCS_BRANCH: z.string().default('v2'),
// Gitea Auto-Setup (password used once to create API token, then cleared)
GITEA_ADMIN_PASSWORD: z.string().default(''),
// SMS Campaigns (Termux Android bridge)
ENABLE_SMS: z.string().default('false'),
TERMUX_API_URL: z.string().default('http://10.0.0.193:5001'),

View File

@@ -0,0 +1,51 @@
import { z } from 'zod';
// --- Author entry (for .authors.yml) ---
export const authorSocialLinkSchema = z.object({
icon: z.string().min(1).max(100),
link: z.string().url().max(500),
name: z.string().max(100).optional(),
});
export const authorEntrySchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
avatar: z.string().max(500).optional(),
social: z.array(authorSocialLinkSchema).max(10).optional(),
});
export const authorsFileSchema = z.object({
authors: z.record(z.string().min(1).max(50), authorEntrySchema),
});
export type AuthorEntry = z.infer<typeof authorEntrySchema>;
export type AuthorsFile = z.infer<typeof authorsFileSchema>;
// --- Blog post frontmatter ---
export const blogFrontmatterSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD format'),
authors: z.array(z.string().min(1)).min(1),
categories: z.array(z.string().min(1)).default([]),
tags: z.array(z.string().min(1)).optional(),
draft: z.boolean().optional(),
slug: z.string().regex(/^[a-z0-9-]+$/).max(200).optional(),
description: z.string().max(500).optional(),
});
export type BlogFrontmatter = z.infer<typeof blogFrontmatterSchema>;
// --- New blog post wizard ---
export const newBlogPostSchema = z.object({
title: z.string().min(1).max(200),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
authors: z.array(z.string()).min(1),
categories: z.array(z.string()).default([]),
draft: z.boolean().default(true),
slug: z.string().regex(/^[a-z0-9-]+$/).max(200).optional(),
description: z.string().max(500).optional(),
});
export type NewBlogPost = z.infer<typeof newBlogPostSchema>;

View File

@@ -0,0 +1,198 @@
import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
import { redis } from '../../config/redis';
import { logger } from '../../utils/logger';
import { docsFilesService, FileNotFoundError } from './docs-files.service';
import { authorsFileSchema, type AuthorsFile, type AuthorEntry, type NewBlogPost } from './blog.schemas';
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
const CATEGORIES_CACHE_KEY = 'DOCS_CACHE:blog:categories';
const CATEGORIES_CACHE_TTL = 300; // 5 minutes
export interface ParsedFrontmatter {
frontmatter: Record<string, unknown>;
body: string;
rawYaml: string;
}
/**
* Parse YAML frontmatter from markdown content.
* Returns null if no frontmatter block found.
*/
function parseFrontmatter(content: string): ParsedFrontmatter | null {
const match = content.match(FRONTMATTER_REGEX);
if (!match) return null;
const rawYaml = match[1];
const body = match[2];
try {
const frontmatter = yamlParse(rawYaml) as Record<string, unknown>;
return { frontmatter: frontmatter || {}, body, rawYaml };
} catch {
return null;
}
}
/**
* Serialize frontmatter object + body back to markdown.
* Uses YAML stringify with options to produce MkDocs Material-compatible output
* (no quoted dates, block arrays).
*/
function serializeFrontmatter(frontmatter: Record<string, unknown>, body: string): string {
// Order keys for consistent output
const ordered: Record<string, unknown> = {};
const keyOrder = ['date', 'authors', 'categories', 'tags', 'draft', 'slug', 'description'];
for (const key of keyOrder) {
if (frontmatter[key] !== undefined) ordered[key] = frontmatter[key];
}
// Include any extra keys not in the order
for (const key of Object.keys(frontmatter)) {
if (!(key in ordered)) ordered[key] = frontmatter[key];
}
const yaml = yamlStringify(ordered, {
lineWidth: 0,
defaultStringType: 'PLAIN',
defaultKeyType: 'PLAIN',
}).trimEnd();
return `---\n${yaml}\n---\n${body}`;
}
/**
* Read and parse the blog/.authors.yml file.
*/
async function readAuthorsFile(): Promise<Record<string, AuthorEntry>> {
try {
const content = await docsFilesService.readFileContent('blog/.authors.yml');
const parsed = yamlParse(content) as { authors?: Record<string, AuthorEntry> };
return parsed?.authors || {};
} catch (err) {
if (err instanceof FileNotFoundError) {
// Create default empty authors file
await docsFilesService.writeFileContent('blog/.authors.yml', 'authors: {}\n');
return {};
}
throw err;
}
}
/**
* Write the blog/.authors.yml file.
* Validates structure with Zod before writing.
*/
async function writeAuthorsFile(authors: Record<string, AuthorEntry>): Promise<void> {
// Validate
authorsFileSchema.parse({ authors });
const yaml = yamlStringify({ authors }, {
lineWidth: 0,
defaultStringType: 'PLAIN',
defaultKeyType: 'PLAIN',
});
await docsFilesService.writeFileContent('blog/.authors.yml', yaml);
}
/**
* Extract all unique categories from existing blog posts.
* Results are cached in Redis for 5 minutes.
*/
async function extractCategories(): Promise<string[]> {
// Try cache
try {
const cached = await redis.get(CATEGORIES_CACHE_KEY);
if (cached) return JSON.parse(cached) as string[];
} catch {
// Ignore cache errors
}
const tree = await docsFilesService.listTree();
const categories = new Set<string>();
// Find blog/posts directory
function findBlogPosts(nodes: typeof tree): typeof tree {
for (const node of nodes) {
if (node.path === 'blog/posts' && node.isDirectory && node.children) {
return node.children;
}
if (node.isDirectory && node.children) {
const result = findBlogPosts(node.children);
if (result.length > 0) return result;
}
}
return [];
}
const posts = findBlogPosts(tree);
// Read each post and extract categories
await Promise.all(
posts
.filter(n => !n.isDirectory && n.name.endsWith('.md'))
.map(async (node) => {
try {
const content = await docsFilesService.readFileContent(node.path);
const parsed = parseFrontmatter(content);
if (parsed?.frontmatter.categories && Array.isArray(parsed.frontmatter.categories)) {
for (const cat of parsed.frontmatter.categories) {
if (typeof cat === 'string') categories.add(cat);
}
}
} catch {
// Skip files that can't be read
}
}),
);
const sorted = Array.from(categories).sort();
// Cache result
try {
await redis.setex(CATEGORIES_CACHE_KEY, CATEGORIES_CACHE_TTL, JSON.stringify(sorted));
} catch {
// Ignore cache errors
}
return sorted;
}
/**
* Scaffold a new blog post with frontmatter.
*/
function scaffoldBlogPost(opts: NewBlogPost): { content: string; suggestedPath: string } {
const slug = opts.slug || slugify(opts.title);
const frontmatter: Record<string, unknown> = {
date: opts.date,
authors: opts.authors,
};
if (opts.categories.length > 0) frontmatter.categories = opts.categories;
if (opts.draft) frontmatter.draft = true;
if (opts.slug) frontmatter.slug = opts.slug;
if (opts.description) frontmatter.description = opts.description;
const body = `\n# ${opts.title}\n\nWrite your post content here.\n\n<!-- more -->\n\nContinue writing below the fold...\n`;
const content = serializeFrontmatter(frontmatter, body);
const suggestedPath = `blog/posts/${opts.date}-${slug}.md`;
return { content, suggestedPath };
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 80);
}
export const blogService = {
parseFrontmatter,
serializeFrontmatter,
readAuthorsFile,
writeAuthorsFile,
extractCategories,
scaffoldBlogPost,
};

View File

@@ -0,0 +1,297 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
import { CONTENT_ROLES } from '../../utils/roles';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { docsAccessService, ShareLinkError } from './docs-access.service';
import { docsFilesService } from './docs-files.service';
import { upsertPolicySchema, createShareLinkSchema } from './docs-access.schemas';
const router = Router();
// --- Access Policy Routes (authenticated, CONTENT_ROLES) ---
// GET /api/docs-access/policy?path=... — get effective policy for a file
router.get(
'/policy',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const path = String(req.query['path'] ?? '').trim();
if (!path) {
res.status(400).json({ error: { message: 'Path query parameter required', code: 'VALIDATION_ERROR' } });
return;
}
const policy = await docsAccessService.getEffectivePolicy(path);
res.json(policy);
} catch (err) {
logger.error('Failed to get doc access policy', err);
next(err);
}
},
);
// PUT /api/docs-access/policy — create or update policy for a path
router.put(
'/policy',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = upsertPolicySchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: { message: 'Invalid policy data', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors } });
return;
}
const { documentPath, isDirectory, allowedEditors } = parsed.data;
const policy = await prisma.docAccessPolicy.upsert({
where: { documentPath },
create: {
documentPath,
isDirectory,
allowedEditors: allowedEditors as unknown as import('@prisma/client').Prisma.InputJsonValue,
createdById: req.user!.id,
},
update: {
isDirectory,
allowedEditors: allowedEditors as unknown as import('@prisma/client').Prisma.InputJsonValue,
},
});
res.json(policy);
} catch (err) {
logger.error('Failed to upsert doc access policy', err);
next(err);
}
},
);
// DELETE /api/docs-access/policy?path=... — remove policy (revert to default)
router.delete(
'/policy',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const path = String(req.query['path'] ?? '').trim();
if (!path) {
res.status(400).json({ error: { message: 'Path query parameter required', code: 'VALIDATION_ERROR' } });
return;
}
await prisma.docAccessPolicy.deleteMany({
where: { documentPath: path },
});
res.json({ success: true });
} catch (err) {
logger.error('Failed to delete doc access policy', err);
next(err);
}
},
);
// GET /api/docs-access/policies — list all policies
router.get(
'/policies',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const policies = await prisma.docAccessPolicy.findMany({
orderBy: { documentPath: 'asc' },
include: { createdBy: { select: { id: true, name: true, email: true } } },
});
res.json({ policies });
} catch (err) {
logger.error('Failed to list doc access policies', err);
next(err);
}
},
);
// --- Share Link Routes ---
// POST /api/docs-access/share/create — generate share link (authenticated, CONTENT_ROLES)
router.post(
'/share/create',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = createShareLinkSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: { message: 'Invalid share link data', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors } });
return;
}
// Verify the file exists
try {
docsFilesService.safeResolve(parsed.data.documentPath);
} catch {
res.status(404).json({ error: { message: 'Document not found', code: 'NOT_FOUND' } });
return;
}
const link = await docsAccessService.generateShareLink(
req.user!.id,
parsed.data.documentPath,
{
canEdit: parsed.data.canEdit,
expiresInHours: parsed.data.expiresInHours,
maxUses: parsed.data.maxUses,
guestName: parsed.data.guestName,
},
);
res.status(201).json(link);
} catch (err) {
logger.error('Failed to create share link', err);
next(err);
}
},
);
// GET /api/docs-access/share/links?path=... — list share links for a document
router.get(
'/share/links',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const path = String(req.query['path'] ?? '').trim();
const where = path ? { documentPath: path } : {};
const links = await prisma.docShareLink.findMany({
where,
orderBy: { createdAt: 'desc' },
include: { createdBy: { select: { id: true, name: true, email: true } } },
});
res.json({ links });
} catch (err) {
logger.error('Failed to list share links', err);
next(err);
}
},
);
// PATCH /api/docs-access/share/:id/revoke — revoke a share link
router.patch(
'/share/:id/revoke',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const link = await prisma.docShareLink.update({
where: { id },
data: { status: 'REVOKED' },
});
res.json(link);
} catch (err) {
logger.error('Failed to revoke share link', err);
next(err);
}
},
);
// DELETE /api/docs-access/share/:id — delete a share link record
router.delete(
'/share/:id',
authenticate,
requireNonTemp,
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await prisma.docShareLink.delete({ where: { id } });
res.json({ success: true });
} catch (err) {
logger.error('Failed to delete share link', err);
next(err);
}
},
);
// --- Public Share Redemption (no auth required) ---
// GET /api/docs-access/share/public/:shareToken — validate token, return doc metadata + collab JWT
router.get(
'/share/public/:shareToken',
async (req: Request, res: Response, next: NextFunction) => {
try {
const shareToken = req.params.shareToken as string;
if (!shareToken || shareToken.length < 32) {
res.status(400).json({ error: { message: 'Invalid share token', code: 'VALIDATION_ERROR' } });
return;
}
const shareLink = await docsAccessService.validateShareToken(shareToken);
// Read document content to get the filename
let documentName = shareLink.documentPath.split('/').pop() || 'Document';
try {
// Check file still exists
docsFilesService.safeResolve(shareLink.documentPath);
} catch {
res.status(404).json({ error: { message: 'Shared document no longer exists', code: 'NOT_FOUND' } });
return;
}
// Generate a short-lived collab JWT for WebSocket access
const collabToken = docsAccessService.generateShareCollabToken({
shareToken,
documentPath: shareLink.documentPath,
canEdit: shareLink.canEdit,
guestName: shareLink.guestName,
});
// Deterministic color for guest
const colorPalette = [
'#E57373', '#81C784', '#64B5F6', '#FFB74D', '#BA68C8',
'#4DD0E1', '#FF8A65', '#AED581', '#9575CD', '#4DB6AC',
'#F06292', '#FFD54F', '#7986CB', '#A1887F', '#90A4AE',
];
const colorIndex = shareToken.charCodeAt(0) % colorPalette.length;
res.json({
documentPath: shareLink.documentPath,
documentName,
canEdit: shareLink.canEdit,
collabToken,
guestIdentity: {
id: `share:${shareToken.substring(0, 8)}`,
name: shareLink.guestName || 'Guest',
color: colorPalette[colorIndex],
},
});
} catch (err) {
if (err instanceof ShareLinkError) {
const statusMap: Record<string, number> = {
SHARE_LINK_NOT_FOUND: 404,
SHARE_LINK_REVOKED: 410,
SHARE_LINK_EXPIRED: 410,
SHARE_LINK_MAX_USES: 429,
};
res.status(statusMap[err.code] || 400).json({ error: { message: err.message, code: err.code } });
return;
}
logger.error('Failed to validate share link', err);
next(err);
}
},
);
export const docsAccessRouter = router;

View File

@@ -0,0 +1,23 @@
import { z } from 'zod';
// --- Access policy ---
export const upsertPolicySchema = z.object({
documentPath: z.string().min(1).max(500),
isDirectory: z.boolean().optional().default(false),
allowedEditors: z.array(z.string().max(200)).min(1),
});
export type UpsertPolicyInput = z.infer<typeof upsertPolicySchema>;
// --- Share link ---
export const createShareLinkSchema = z.object({
documentPath: z.string().min(1).max(500),
canEdit: z.boolean().optional().default(true),
expiresInHours: z.number().int().positive().max(720).optional(), // max 30 days
maxUses: z.number().int().positive().max(1000).optional(),
guestName: z.string().max(200).optional(),
});
export type CreateShareLinkInput = z.infer<typeof createShareLinkSchema>;

View File

@@ -0,0 +1,286 @@
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import { UserRole } from '@prisma/client';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { docsFilesService } from './docs-files.service';
import type { CreateShareLinkInput } from './docs-access.schemas';
// --- Access Policy Resolution ---
interface EffectivePolicy {
id: string | null;
documentPath: string;
isDirectory: boolean;
allowedEditors: string[];
isDefault: boolean; // true if no policy found (all content editors)
}
/**
* Resolve the most specific applicable policy for a document path.
* Walks from exact path up through parent directories.
* Returns default "all content editors" if no policy found.
*/
async function getEffectivePolicy(documentPath: string): Promise<EffectivePolicy> {
// Normalize path: remove leading/trailing slashes
const normalized = documentPath.replace(/^\/+|\/+$/g, '');
// 1. Check for exact file match
const exactPolicy = await prisma.docAccessPolicy.findUnique({
where: { documentPath: normalized },
});
if (exactPolicy) {
return {
id: exactPolicy.id,
documentPath: exactPolicy.documentPath,
isDirectory: exactPolicy.isDirectory,
allowedEditors: exactPolicy.allowedEditors as string[],
isDefault: false,
};
}
// 2. Walk up directory hierarchy looking for directory policies
const segments = normalized.split('/');
for (let i = segments.length - 1; i >= 1; i--) {
const dirPath = segments.slice(0, i).join('/');
const dirPolicy = await prisma.docAccessPolicy.findUnique({
where: { documentPath: dirPath },
});
if (dirPolicy && dirPolicy.isDirectory) {
return {
id: dirPolicy.id,
documentPath: dirPolicy.documentPath,
isDirectory: true,
allowedEditors: dirPolicy.allowedEditors as string[],
isDefault: false,
};
}
}
// 3. No policy found — default to all content editors
return {
id: null,
documentPath: normalized,
isDirectory: false,
allowedEditors: ['all_content_editors'],
isDefault: true,
};
}
/**
* Check if a user can edit a specific document.
* SUPER_ADMIN always passes.
*/
async function canUserEdit(
userId: string,
userRoles: UserRole[],
documentPath: string,
): Promise<boolean> {
// SUPER_ADMIN always passes
if (userRoles.includes(UserRole.SUPER_ADMIN)) return true;
const policy = await getEffectivePolicy(documentPath);
for (const editor of policy.allowedEditors) {
if (editor === 'all_content_editors') {
// Check if user has any CONTENT_ROLES
if (userRoles.includes(UserRole.CONTENT_ADMIN) || userRoles.includes(UserRole.SUPER_ADMIN)) {
return true;
}
} else if (editor.startsWith('user:')) {
if (editor === `user:${userId}`) return true;
} else if (editor.startsWith('role:')) {
const role = editor.substring(5) as UserRole;
if (userRoles.includes(role)) return true;
}
}
return false;
}
// --- Share Link Management ---
interface ShareLinkOptions {
canEdit?: boolean;
expiresInHours?: number;
maxUses?: number;
guestName?: string;
}
/**
* Generate a share link for a document.
*/
async function generateShareLink(
createdById: string,
documentPath: string,
options: ShareLinkOptions = {},
): Promise<{ id: string; shareToken: string; documentPath: string }> {
// Validate the file exists
docsFilesService.safeResolve(documentPath);
const shareToken = crypto.randomBytes(24).toString('hex');
const expiresAt = options.expiresInHours
? new Date(Date.now() + options.expiresInHours * 60 * 60 * 1000)
: null;
const link = await prisma.docShareLink.create({
data: {
documentPath: documentPath.replace(/^\/+|\/+$/g, ''),
shareToken,
canEdit: options.canEdit ?? true,
expiresAt,
maxUses: options.maxUses ?? null,
guestName: options.guestName ?? null,
createdById,
},
});
return { id: link.id, shareToken: link.shareToken, documentPath: link.documentPath };
}
/**
* Validate a share token. Returns the share link or throws.
* Increments use count on successful validation.
*/
async function validateShareToken(shareToken: string): Promise<{
id: string;
documentPath: string;
canEdit: boolean;
guestName: string | null;
}> {
const link = await prisma.docShareLink.findUnique({
where: { shareToken },
});
if (!link) throw new ShareLinkError('Share link not found', 'SHARE_LINK_NOT_FOUND');
if (link.status !== 'ACTIVE') throw new ShareLinkError('Share link has been revoked', 'SHARE_LINK_REVOKED');
if (link.expiresAt && link.expiresAt < new Date()) {
// Auto-expire
await prisma.docShareLink.update({
where: { id: link.id },
data: { status: 'EXPIRED' },
});
throw new ShareLinkError('Share link has expired', 'SHARE_LINK_EXPIRED');
}
if (link.maxUses && link.useCount >= link.maxUses) {
throw new ShareLinkError('Share link has reached maximum uses', 'SHARE_LINK_MAX_USES');
}
// Increment use count
await prisma.docShareLink.update({
where: { id: link.id },
data: { useCount: { increment: 1 } },
});
return {
id: link.id,
documentPath: link.documentPath,
canEdit: link.canEdit,
guestName: link.guestName,
};
}
/**
* Generate a short-lived JWT for a share-link guest to use with the collab WebSocket.
*/
function generateShareCollabToken(shareLink: {
shareToken: string;
documentPath: string;
canEdit: boolean;
guestName: string | null;
}): string {
return jwt.sign(
{
type: 'doc_share',
shareToken: shareLink.shareToken,
documentPath: shareLink.documentPath,
canEdit: shareLink.canEdit,
guestName: shareLink.guestName || 'Guest',
},
env.JWT_INVITE_SECRET,
{ expiresIn: '4h' },
);
}
// --- Cascade Operations ---
/**
* Update access policies and share links when a file is renamed.
*/
async function cascadeRename(oldPath: string, newPath: string): Promise<void> {
const normalizedOld = oldPath.replace(/^\/+|\/+$/g, '');
const normalizedNew = newPath.replace(/^\/+|\/+$/g, '');
try {
// Update exact match policy
await prisma.docAccessPolicy.updateMany({
where: { documentPath: normalizedOld },
data: { documentPath: normalizedNew },
});
// Update share links
await prisma.docShareLink.updateMany({
where: { documentPath: normalizedOld },
data: { documentPath: normalizedNew },
});
// Update doc watches
await prisma.docWatch.updateMany({
where: { filePath: normalizedOld },
data: { filePath: normalizedNew },
});
logger.info(`Docs access: cascaded rename ${normalizedOld}${normalizedNew}`);
} catch (err) {
logger.warn('Failed to cascade rename for docs access:', err);
}
}
/**
* Revoke share links and delete policy when a file is deleted.
*/
async function cascadeDelete(path: string): Promise<void> {
const normalized = path.replace(/^\/+|\/+$/g, '');
try {
// Revoke active share links
await prisma.docShareLink.updateMany({
where: { documentPath: normalized, status: 'ACTIVE' },
data: { status: 'REVOKED' },
});
// Delete access policy
await prisma.docAccessPolicy.deleteMany({
where: { documentPath: normalized },
});
// Delete watches
await prisma.docWatch.deleteMany({
where: { filePath: normalized },
});
logger.info(`Docs access: cascaded delete for ${normalized}`);
} catch (err) {
logger.warn('Failed to cascade delete for docs access:', err);
}
}
export class ShareLinkError extends Error {
code: string;
constructor(message: string, code: string) {
super(message);
this.name = 'ShareLinkError';
this.code = code;
}
}
export const docsAccessService = {
getEffectivePolicy,
canUserEdit,
generateShareLink,
validateShareToken,
generateShareCollabToken,
cascadeRename,
cascadeDelete,
};

View File

@@ -10,8 +10,9 @@ import { env } from '../../config/env';
import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { logger } from '../../utils/logger';
import { CONTENT_ROLES } from '../../utils/roles';
import { CONTENT_ROLES, getUserRoles } from '../../utils/roles';
import { docsFilesService } from './docs-files.service';
import { docsAccessService } from './docs-access.service';
// --- Metrics ---
import { Gauge } from 'prom-client';
@@ -39,6 +40,15 @@ interface TokenPayload {
roles?: UserRole[];
}
// Share-link collab JWT payload (signed with JWT_INVITE_SECRET)
interface ShareTokenPayload {
type: 'doc_share';
shareToken: string;
documentPath: string;
canEdit: boolean;
guestName: string;
}
// --- Deterministic color from user ID ---
const COLLAB_COLORS = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
@@ -71,26 +81,6 @@ const docsExtension: Extension = {
throw new Error('Authentication required');
}
// Verify JWT
let payload: TokenPayload;
try {
payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
} catch {
throw new Error('Invalid or expired token');
}
const roles = payload.roles || [payload.role];
// Check CONTENT_ROLES for write access
const hasWriteAccess = roles.some(r => (CONTENT_ROLES as string[]).includes(r));
if (!hasWriteAccess) {
// Allow read-only for any authenticated non-TEMP user
if (roles.includes(UserRole.TEMP)) {
throw new Error('TEMP users cannot access collaboration');
}
data.connectionConfig.readOnly = true;
}
// Validate document path (prevent path traversal)
try {
docsFilesService.safeResolve(documentName);
@@ -98,45 +88,129 @@ const docsExtension: Extension = {
throw new Error('Invalid document path');
}
// Rate limit: max connections per user
const currentCount = connectionsPerUser.get(payload.id) || 0;
if (currentCount >= MAX_CONNECTIONS_PER_USER) {
// Try standard JWT (access token) first
let payload: TokenPayload | null = null;
try {
payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
} catch {
// Standard JWT failed — try share-link JWT below
}
if (payload) {
// --- Standard authenticated user flow ---
const roles = payload.roles || [payload.role];
// Check CONTENT_ROLES for write access
const hasWriteAccess = roles.some(r => (CONTENT_ROLES as string[]).includes(r));
if (!hasWriteAccess) {
if (roles.includes(UserRole.TEMP)) {
throw new Error('TEMP users cannot access collaboration');
}
data.connectionConfig.readOnly = true;
}
// Per-file access policy check
if (hasWriteAccess) {
try {
const canEdit = await docsAccessService.canUserEdit(payload.id, roles as UserRole[], documentName);
if (!canEdit) {
data.connectionConfig.readOnly = true;
}
} catch {
// If policy check fails, default to read-only
data.connectionConfig.readOnly = true;
}
}
// Rate limit: max connections per user
const currentCount = connectionsPerUser.get(payload.id) || 0;
if (currentCount >= MAX_CONNECTIONS_PER_USER) {
throw new Error('Too many concurrent connections');
}
// Rate limit: max concurrent documents
if (hocuspocus.getDocumentsCount() >= MAX_CONCURRENT_DOCUMENTS) {
if (!hocuspocus.documents.has(documentName)) {
throw new Error('Too many concurrent documents');
}
}
// Track connection
connectionsPerUser.set(payload.id, currentCount + 1);
// Look up user name from DB
let userName = payload.email.split('@')[0];
try {
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { name: true },
});
if (user?.name) userName = user.name;
} catch {
// Fall back to email prefix
}
data.context.user = {
id: payload.id,
email: payload.email,
name: userName,
color: getUserColor(payload.id),
roles,
};
logger.info(`Docs collab: ${userName} connected to ${documentName}`);
return;
}
// --- Share-link guest flow ---
let sharePayload: ShareTokenPayload;
try {
sharePayload = jwt.verify(token, env.JWT_INVITE_SECRET, { algorithms: ['HS256'] }) as ShareTokenPayload;
} catch {
throw new Error('Invalid or expired token');
}
// Verify it's a doc_share token
if (sharePayload.type !== 'doc_share') {
throw new Error('Invalid token type');
}
// Verify the document matches
if (sharePayload.documentPath !== documentName) {
throw new Error('Token does not match this document');
}
// Re-validate share token against DB (could have been revoked since page load)
try {
await docsAccessService.validateShareToken(sharePayload.shareToken);
} catch {
throw new Error('Share link has been revoked or expired');
}
// Set read-only if share link doesn't grant edit
if (!sharePayload.canEdit) {
data.connectionConfig.readOnly = true;
}
// Rate limit for share guests (keyed on share token prefix)
const guestKey = `share:${sharePayload.shareToken.substring(0, 8)}`;
const guestCount = connectionsPerUser.get(guestKey) || 0;
if (guestCount >= MAX_CONNECTIONS_PER_USER) {
throw new Error('Too many concurrent connections');
}
connectionsPerUser.set(guestKey, guestCount + 1);
// Rate limit: max concurrent documents
if (hocuspocus.getDocumentsCount() >= MAX_CONCURRENT_DOCUMENTS) {
// Only block if this is a NEW document (not joining existing)
if (!hocuspocus.documents.has(documentName)) {
throw new Error('Too many concurrent documents');
}
}
// Track connection
connectionsPerUser.set(payload.id, currentCount + 1);
// Look up user name from DB
let userName = payload.email.split('@')[0];
try {
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { name: true },
});
if (user?.name) userName = user.name;
} catch {
// Fall back to email prefix
}
// Set context for use in other hooks
// Set guest context
data.context.user = {
id: payload.id,
email: payload.email,
name: userName,
color: getUserColor(payload.id),
roles,
id: guestKey,
email: '',
name: sharePayload.guestName || 'Guest',
color: getUserColor(sharePayload.shareToken),
roles: [],
isShareGuest: true,
};
logger.info(`Docs collab: ${userName} connected to ${documentName}`);
logger.info(`Docs collab: guest "${sharePayload.guestName || 'Guest'}" connected to ${documentName} via share link`);
},
async onLoadDocument(data) {

View File

@@ -317,6 +317,54 @@ async function searchFiles(
return matches.slice(0, limit).map(({ name, path }) => ({ name, path }));
}
/**
* Search within file contents for a query string.
* Returns matching files with line numbers and context.
*/
async function searchContent(
query: string,
limit = 10,
): Promise<{ path: string; name: string; matches: { line: number; text: string; context: string }[] }[]> {
if (!query || query.length < 2) return [];
const tree = await listTree();
const q = query.toLowerCase();
const results: { path: string; name: string; matches: { line: number; text: string; context: string }[] }[] = [];
async function walk(nodes: FileNode[]) {
for (const node of nodes) {
if (results.length >= limit) return;
if (node.isDirectory) {
if (node.children) await walk(node.children);
} else if (node.name.endsWith('.md') || node.name.endsWith('.txt') || node.name.endsWith('.yml') || node.name.endsWith('.yaml')) {
try {
const content = await readFileContent(node.path);
const lines = content.split('\n');
const matches: { line: number; text: string; context: string }[] = [];
for (let i = 0; i < lines.length && matches.length < 5; i++) {
if (lines[i].toLowerCase().includes(q)) {
const contextStart = Math.max(0, i - 1);
const contextEnd = Math.min(lines.length - 1, i + 1);
const context = lines.slice(contextStart, contextEnd + 1).join('\n');
matches.push({ line: i + 1, text: lines[i].trim(), context });
}
}
if (matches.length > 0) {
results.push({ path: node.path, name: node.name, matches });
}
} catch {
// Skip files that can't be read
}
}
}
}
await walk(tree);
return results;
}
export const docsFilesService = {
listTree,
readFileContent,
@@ -329,4 +377,5 @@ export const docsFilesService = {
isEditableFile,
invalidateTreeCache,
searchFiles,
searchContent,
};

View File

@@ -0,0 +1,267 @@
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
/**
* 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;
}
function getApiToken(): string {
return env.GITEA_API_TOKEN || '';
}
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 = 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 = 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 = 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`, {
signal: controller.signal,
});
return res.ok;
} finally {
clearTimeout(timeout);
}
} catch {
return false;
}
}
export const docsHistoryService = {
commitFile,
getFileHistory,
getFileAtCommit,
restoreRevision,
isAvailable,
};

View File

@@ -0,0 +1,173 @@
import { stat } from 'fs/promises';
import { resolve as pathResolve } from 'path';
import { parse as yamlParse } from 'yaml';
import { env } from '../../config/env';
import { redis } from '../../config/redis';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { docsFilesService, type FileNode } from './docs-files.service';
const METADATA_CACHE_KEY = 'DOCS_CACHE:metadata';
const METADATA_CACHE_TTL = 300; // 5 minutes
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
export interface DocPageMetadata {
path: string;
title: string | null;
tags: string[];
description: string | null;
status: string | null;
lastModified: string | null;
wordCount: number;
hasAccessPolicy: boolean;
}
export interface DocMetadataWarning {
type: 'no-tags' | 'no-description' | 'orphaned' | 'stale';
paths: string[];
}
export interface DocMetadataResult {
totalPages: number;
pages: DocPageMetadata[];
warnings: DocMetadataWarning[];
}
/**
* Get comprehensive metadata for all documentation pages.
* Walks the file tree, parses frontmatter, cross-references with analytics and nav.
*/
async function getMetadata(): Promise<DocMetadataResult> {
// Try cache
try {
const cached = await redis.get(METADATA_CACHE_KEY);
if (cached) return JSON.parse(cached) as DocMetadataResult;
} catch {
// Ignore cache errors
}
const tree = await docsFilesService.listTree();
const docsRoot = pathResolve(env.MKDOCS_DOCS_PATH);
// Collect all .md files
const mdFiles: string[] = [];
function walk(nodes: FileNode[]) {
for (const node of nodes) {
if (node.isDirectory && node.children) {
walk(node.children);
} else if (node.name.endsWith('.md')) {
mdFiles.push(node.path);
}
}
}
walk(tree);
// Get access policies in one query
const policies = await prisma.docAccessPolicy.findMany({
select: { documentPath: true },
});
const policyPaths = new Set(policies.map(p => p.documentPath));
// Parse each file
const pages: DocPageMetadata[] = await Promise.all(
mdFiles.map(async (filePath) => {
try {
const content = await docsFilesService.readFileContent(filePath);
const fullPath = pathResolve(docsRoot, filePath);
// Get file mod time
let lastModified: string | null = null;
try {
const fileStats = await stat(fullPath);
lastModified = fileStats.mtime.toISOString();
} catch {
// Ignore stat errors
}
// Parse frontmatter
let title: string | null = null;
let tags: string[] = [];
let description: string | null = null;
let status: string | null = null;
const match = content.match(FRONTMATTER_REGEX);
if (match) {
try {
const fm = yamlParse(match[1]) as Record<string, unknown>;
if (typeof fm?.title === 'string') title = fm.title;
if (Array.isArray(fm?.tags)) tags = fm.tags.filter((t): t is string => typeof t === 'string');
if (typeof fm?.description === 'string') description = fm.description;
if (typeof fm?.status === 'string') status = fm.status;
} catch {
// Invalid frontmatter
}
}
// Extract title from first # heading if not in frontmatter
if (!title) {
const headingMatch = content.match(/^#\s+(.+)$/m);
if (headingMatch) title = headingMatch[1].trim();
}
// Word count (body only, rough estimate)
const bodyText = match ? match[2] : content;
const wordCount = bodyText.split(/\s+/).filter(w => w.length > 0).length;
return {
path: filePath,
title,
tags,
description,
status,
lastModified,
wordCount,
hasAccessPolicy: policyPaths.has(filePath),
};
} catch {
return {
path: filePath,
title: null,
tags: [],
description: null,
status: null,
lastModified: null,
wordCount: 0,
hasAccessPolicy: false,
};
}
}),
);
// Build warnings
const warnings: DocMetadataWarning[] = [];
const noTags = pages.filter(p => p.tags.length === 0 && !p.path.startsWith('blog/')).map(p => p.path);
if (noTags.length > 0) warnings.push({ type: 'no-tags', paths: noTags });
const noDescription = pages.filter(p => !p.description && !p.path.startsWith('blog/')).map(p => p.path);
if (noDescription.length > 0) warnings.push({ type: 'no-description', paths: noDescription });
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const stale = pages.filter(p => p.lastModified && p.lastModified < thirtyDaysAgo).map(p => p.path);
if (stale.length > 0) warnings.push({ type: 'stale', paths: stale });
const result: DocMetadataResult = {
totalPages: pages.length,
pages,
warnings,
};
// Cache result
try {
await redis.setex(METADATA_CACHE_KEY, METADATA_CACHE_TTL, JSON.stringify(result));
} catch {
// Ignore cache errors
}
return result;
}
export const docsMetadataService = {
getMetadata,
};

View File

@@ -0,0 +1,404 @@
export interface DocTemplate {
id: string;
name: string;
description: string;
category: 'blog' | 'guide' | 'reference' | 'planning' | 'general';
icon: string; // Material icon name
filenamePattern: string; // e.g. "{{slug}}.md" or "blog/posts/{{date}}-{{slug}}.md"
content: string;
}
const today = () => new Date().toISOString().split('T')[0];
const BUILT_IN_TEMPLATES: DocTemplate[] = [
{
id: 'blog-post',
name: 'Blog Post',
description: 'A blog post with MkDocs Material frontmatter',
category: 'blog',
icon: 'article',
filenamePattern: 'blog/posts/{{date}}-{{slug}}.md',
content: `---
date: {{date}}
authors:
- admin
categories:
- General
draft: true
---
# {{title}}
Write your intro paragraph here. This appears on the blog index page.
<!-- more -->
## Main Content
Continue writing below the fold...
`,
},
{
id: 'how-to-guide',
name: 'How-To Guide',
description: 'Step-by-step guide for a specific task',
category: 'guide',
icon: 'menu_book',
filenamePattern: 'docs/{{slug}}.md',
content: `---
tags:
- guide
---
# How to {{title}}
Brief description of what this guide covers and who it's for.
## Prerequisites
- Prerequisite 1
- Prerequisite 2
## Steps
### Step 1: Getting Started
Description of the first step.
### Step 2: Configuration
Description of the second step.
### Step 3: Verification
How to verify everything is working.
## Troubleshooting
Common issues and their solutions.
## Next Steps
What to do after completing this guide.
`,
},
{
id: 'api-reference',
name: 'API Reference',
description: 'API endpoint documentation',
category: 'reference',
icon: 'api',
filenamePattern: 'docs/api/{{slug}}.md',
content: `---
tags:
- api
- reference
---
# {{title}} API
## Overview
Brief description of this API group.
## Endpoints
### GET /api/endpoint
Description of what this endpoint does.
**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| id | string | Yes | Resource ID |
**Response:**
\`\`\`json
{
"id": "abc123",
"name": "Example"
}
\`\`\`
### POST /api/endpoint
Description of what this endpoint does.
**Request Body:**
\`\`\`json
{
"name": "Example"
}
\`\`\`
## Error Codes
| Code | Description |
|------|-------------|
| 400 | Bad Request |
| 401 | Unauthorized |
| 404 | Not Found |
`,
},
{
id: 'adr',
name: 'Architecture Decision Record',
description: 'Document an architecture decision',
category: 'planning',
icon: 'architecture',
filenamePattern: 'docs/architecture/adr-{{slug}}.md',
content: `---
tags:
- architecture
- decision
---
# ADR: {{title}}
**Date:** {{date}}
**Status:** Proposed
## Context
What is the issue that we're seeing that is motivating this decision or change?
## Decision
What is the change that we're proposing and/or doing?
## Consequences
What becomes easier or more difficult to do because of this change?
### Positive
- Benefit 1
- Benefit 2
### Negative
- Drawback 1
- Drawback 2
### Neutral
- Side effect 1
`,
},
{
id: 'faq',
name: 'FAQ Page',
description: 'Frequently asked questions with collapsible answers',
category: 'general',
icon: 'quiz',
filenamePattern: 'docs/{{slug}}.md',
content: `---
tags:
- faq
---
# {{title}}
Frequently asked questions about this topic.
??? question "Question 1?"
Answer to question 1. You can include **formatting**, links, and code blocks.
??? question "Question 2?"
Answer to question 2.
\`\`\`bash
# Example command
echo "hello world"
\`\`\`
??? question "Question 3?"
Answer to question 3.
`,
},
{
id: 'release-notes',
name: 'Release Notes',
description: 'Release notes for a version',
category: 'planning',
icon: 'new_releases',
filenamePattern: 'docs/{{slug}}.md',
content: `---
tags:
- release
---
# Release Notes — {{title}}
**Release Date:** {{date}}
## Highlights
Brief summary of the most important changes.
## New Features
- **Feature Name** — Description of the new feature
## Improvements
- **Improvement** — Description of the improvement
## Bug Fixes
- **Fix** — Description of what was fixed
## Breaking Changes
!!! warning "Breaking Changes"
List any breaking changes that require user action.
## Upgrade Instructions
Steps needed to upgrade from the previous version.
`,
},
{
id: 'tutorial',
name: 'Tutorial',
description: 'In-depth tutorial with learning objectives',
category: 'guide',
icon: 'school',
filenamePattern: 'docs/{{slug}}.md',
content: `---
tags:
- tutorial
---
# {{title}}
## What You'll Learn
By the end of this tutorial, you will be able to:
- Learning objective 1
- Learning objective 2
- Learning objective 3
## Prerequisites
!!! info "Before you begin"
- Prerequisite 1
- Prerequisite 2
## Part 1: Setup
Content for part 1...
## Part 2: Implementation
Content for part 2...
## Part 3: Testing
Content for part 3...
## Summary
Recap of what was covered and next steps.
## Further Reading
- [Related Guide 1](link)
- [Related Guide 2](link)
`,
},
{
id: 'meeting-notes',
name: 'Meeting Notes',
description: 'Meeting notes template with agenda and action items',
category: 'general',
icon: 'groups',
filenamePattern: 'docs/{{slug}}.md',
content: `---
tags:
- meeting
---
# {{title}}
**Date:** {{date}}
**Attendees:**
## Agenda
1. Item 1
2. Item 2
3. Item 3
## Discussion Notes
### Topic 1
Notes...
### Topic 2
Notes...
## Action Items
- [ ] Action item 1 — @assignee — due date
- [ ] Action item 2 — @assignee — due date
## Next Meeting
Date and topics for next meeting.
`,
},
];
function getTemplates(): DocTemplate[] {
return BUILT_IN_TEMPLATES;
}
function applyTemplate(
templateId: string,
variables: { title: string; date?: string; slug?: string; author?: string },
): { content: string; suggestedPath: string } | null {
const template = BUILT_IN_TEMPLATES.find(t => t.id === templateId);
if (!template) return null;
const date = variables.date || today();
const slug = variables.slug || slugify(variables.title);
const author = variables.author || 'admin';
let content = template.content
.replace(/\{\{title\}\}/g, variables.title)
.replace(/\{\{date\}\}/g, date)
.replace(/\{\{slug\}\}/g, slug)
.replace(/\{\{author\}\}/g, author);
let suggestedPath = template.filenamePattern
.replace(/\{\{title\}\}/g, variables.title)
.replace(/\{\{date\}\}/g, date)
.replace(/\{\{slug\}\}/g, slug);
return { content, suggestedPath };
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 80);
}
export const docsTemplatesService = {
getTemplates,
applyTemplate,
};

View File

@@ -2,10 +2,11 @@ import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer';
import { rm } from 'fs/promises';
import { extname, basename } from 'path';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../middleware/auth.middleware';
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
import { env } from '../../config/env';
import { CONTENT_ROLES } from '../../utils/roles';
import { CONTENT_ROLES, getUserRoles } from '../../utils/roles';
import { logger } from '../../utils/logger';
import { isServiceOnline } from '../../utils/health-check';
import { cm_docs_operations } from '../../utils/metrics';
@@ -15,6 +16,12 @@ import { mkdocsConfigService } from './mkdocs-config.service';
import { headerBuilderService } from './header-builder.service';
import { headerConfigSchema } from './header-builder.schemas';
import { docsResetService } from './docs-reset.service';
import { blogService } from './blog.service';
import { newBlogPostSchema, authorsFileSchema } from './blog.schemas';
import { docsAccessService } from './docs-access.service';
import { docsHistoryService } from './docs-history.service';
import { docsTemplatesService } from './docs-templates.service';
import { docsMetadataService } from './docs-metadata.service';
const router = Router();
router.use(authenticate);
@@ -265,6 +272,27 @@ router.get(
},
);
// GET /api/docs/files/search-content — search within file contents
router.get(
'/files/search-content',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const query = String(req.query['q'] ?? '').trim();
if (!query || query.length < 2) {
res.json({ results: [] });
return;
}
const limit = Math.min(Math.max(Number(req.query['limit']) || 10, 1), 30);
const results = await docsFilesService.searchContent(query, limit);
res.json({ results });
} catch (err) {
logger.error('Failed to search docs content', err);
next(err);
}
},
);
// POST /api/docs/files/rename — rename/move file
router.post(
'/files/rename',
@@ -277,7 +305,18 @@ router.post(
res.status(400).json({ error: { message: 'Both "from" and "to" paths are required', code: 'VALIDATION_ERROR' } });
return;
}
// Check access on both source and destination
const userRoles = getUserRoles(req.user!);
const canEditFrom = await docsAccessService.canUserEdit(req.user!.id, userRoles, from);
if (!canEditFrom) {
res.status(403).json({ error: { message: 'You do not have edit access to this document', code: 'DOC_ACCESS_DENIED' } });
return;
}
await docsFilesService.renameFile(from, to);
// Cascade rename for access policies and share links
docsAccessService.cascadeRename(from, to).catch(() => {});
// Invalidate old path's collaboration state
docsCollabService.invalidateDocument(from).catch(() => {});
res.json({ success: true });
@@ -319,6 +358,15 @@ router.put(
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
return;
}
// Per-file access check
const userRoles = getUserRoles(req.user!);
const canEdit = await docsAccessService.canUserEdit(req.user!.id, userRoles, filePath);
if (!canEdit) {
res.status(403).json({ error: { message: 'You do not have edit access to this document', code: 'DOC_ACCESS_DENIED' } });
return;
}
const { content } = req.body as { content?: string };
if (typeof content !== 'string') {
res.status(400).json({ error: { message: 'Content string required', code: 'VALIDATION_ERROR' } });
@@ -327,6 +375,9 @@ router.put(
await docsFilesService.writeFileContent(filePath, content);
// Invalidate collaboration state so next session starts fresh from disk
docsCollabService.invalidateDocument(filePath).catch(() => {});
// Fire-and-forget: commit to Gitea for version history
const userName = req.user!.email;
docsHistoryService.commitFile(filePath, content, userName, req.user!.email).catch(() => {});
res.json({ success: true, path: filePath });
} catch (err) {
handleFileError(err, res, next);
@@ -367,7 +418,18 @@ router.delete(
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
return;
}
// Per-file access check
const userRoles = getUserRoles(req.user!);
const canEdit = await docsAccessService.canUserEdit(req.user!.id, userRoles, filePath);
if (!canEdit) {
res.status(403).json({ error: { message: 'You do not have edit access to this document', code: 'DOC_ACCESS_DENIED' } });
return;
}
await docsFilesService.deleteFile(filePath);
// Cascade delete for access policies and share links
docsAccessService.cascadeDelete(filePath).catch(() => {});
// Invalidate collaboration state for deleted file
docsCollabService.invalidateDocument(filePath).catch(() => {});
res.json({ success: true });
@@ -410,4 +472,200 @@ function handleFileError(err: unknown, res: Response, next: NextFunction): void
next(err);
}
// --- Blog Endpoints ---
// GET /api/docs/blog/authors — read .authors.yml
router.get(
'/blog/authors',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const authors = await blogService.readAuthorsFile();
res.json({ authors });
} catch (err) {
logger.error('Failed to read blog authors', err);
next(err);
}
},
);
// PUT /api/docs/blog/authors — update .authors.yml
router.put(
'/blog/authors',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { authors } = req.body as { authors?: Record<string, unknown> };
if (!authors || typeof authors !== 'object') {
res.status(400).json({ error: { message: 'Authors object required', code: 'VALIDATION_ERROR' } });
return;
}
const parsed = authorsFileSchema.safeParse({ authors });
if (!parsed.success) {
res.status(400).json({ error: { message: 'Invalid authors data', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors } });
return;
}
await blogService.writeAuthorsFile(parsed.data.authors);
res.json({ success: true });
} catch (err) {
logger.error('Failed to update blog authors', err);
next(err);
}
},
);
// GET /api/docs/blog/categories — deduplicated categories from posts
router.get(
'/blog/categories',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const categories = await blogService.extractCategories();
res.json({ categories });
} catch (err) {
logger.error('Failed to extract blog categories', err);
next(err);
}
},
);
// POST /api/docs/blog/posts — create new blog post from wizard
router.post(
'/blog/posts',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = newBlogPostSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: { message: 'Invalid blog post data', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors } });
return;
}
const { content, suggestedPath } = blogService.scaffoldBlogPost(parsed.data);
await docsFilesService.createFile(suggestedPath, content);
// Fire-and-forget: commit to Gitea
const userName = req.user!.email;
docsHistoryService.commitFile(suggestedPath, content, userName, req.user!.email).catch(() => {});
res.status(201).json({ success: true, path: suggestedPath });
} catch (err) {
handleFileError(err, res, next);
}
},
);
// --- Version History Endpoints ---
// GET /api/docs/history/* — get commit history for a file
router.get(
'/history/*',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const filePath = extractWildcardPath(req);
if (!filePath) {
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
return;
}
const limit = Math.min(Math.max(Number(req.query['limit']) || 20, 1), 50);
const commits = await docsHistoryService.getFileHistory(filePath, limit);
res.json({ commits });
} catch (err) {
logger.error('Failed to get docs history', err);
next(err);
}
},
);
// GET /api/docs/revision/:sha/* — get file content at specific commit
router.get(
'/revision/:sha/*',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const sha = req.params.sha as string;
// Extract path after /revision/:sha/
const params = req.params as Record<string, string | string[]>;
const wildcardParam = params[0] || params['0'];
const filePath = Array.isArray(wildcardParam) ? wildcardParam.join('/') : (wildcardParam || '');
if (!filePath || !sha) {
res.status(400).json({ error: { message: 'SHA and file path required', code: 'VALIDATION_ERROR' } });
return;
}
const content = await docsHistoryService.getFileAtCommit(filePath, sha);
if (content === null) {
res.status(404).json({ error: { message: 'Revision not found', code: 'NOT_FOUND' } });
return;
}
res.json({ sha, path: filePath, content });
} catch (err) {
logger.error('Failed to get docs revision', err);
next(err);
}
},
);
// POST /api/docs/restore/:sha/* — restore file to a previous version
router.post(
'/restore/:sha/*',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const sha = req.params.sha as string;
const params = req.params as Record<string, string | string[]>;
const wildcardParam = params[0] || params['0'];
const filePath = Array.isArray(wildcardParam) ? wildcardParam.join('/') : (wildcardParam || '');
if (!filePath || !sha) {
res.status(400).json({ error: { message: 'SHA and file path required', code: 'VALIDATION_ERROR' } });
return;
}
const userName = req.user!.email;
const content = await docsHistoryService.restoreRevision(filePath, sha, userName, req.user!.email);
if (content === null) {
res.status(404).json({ error: { message: 'Could not restore revision', code: 'RESTORE_FAILED' } });
return;
}
// Also update the disk file
await docsFilesService.writeFileContent(filePath, content);
docsCollabService.invalidateDocument(filePath).catch(() => {});
res.json({ success: true, path: filePath });
} catch (err) {
logger.error('Failed to restore docs revision', err);
next(err);
}
},
);
// --- Templates ---
// GET /api/docs/templates — list available templates
router.get(
'/templates',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response) => {
const templates = docsTemplatesService.getTemplates();
res.json({ templates });
},
);
// --- Metadata Dashboard ---
// GET /api/docs/metadata — full metadata for all docs pages
router.get(
'/metadata',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const metadata = await docsMetadataService.getMetadata();
res.json(metadata);
} catch (err) {
logger.error('Failed to get docs metadata', err);
next(err);
}
},
);
export const docsRouter = router;

View File

@@ -0,0 +1,70 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { authenticate } from '../../middleware/auth.middleware';
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
import { logger } from '../../utils/logger';
import { giteaSetupService } from './gitea-setup.service';
const router = Router();
router.use(authenticate);
router.use(requireNonTemp);
router.use(requireRole('SUPER_ADMIN'));
const credentialsSchema = z.object({
username: z.string().min(1).max(100),
password: z.string().min(1).max(200),
});
// GET /api/gitea/setup/status — check current setup state
router.get(
'/status',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const status = await giteaSetupService.checkStatus();
res.json(status);
} catch (err) {
logger.error('Failed to check Gitea setup status', err);
next(err);
}
},
);
// POST /api/gitea/setup/test-connection — test basic auth credentials
router.post(
'/test-connection',
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = credentialsSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: { message: 'Username and password required', code: 'VALIDATION_ERROR' } });
return;
}
const result = await giteaSetupService.testConnection(parsed.data.username, parsed.data.password);
res.json(result);
} catch (err) {
logger.error('Failed to test Gitea connection', err);
next(err);
}
},
);
// POST /api/gitea/setup/run — run full setup
router.post(
'/run',
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = credentialsSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: { message: 'Username and password required', code: 'VALIDATION_ERROR' } });
return;
}
const result = await giteaSetupService.runFullSetup(parsed.data.username, parsed.data.password);
res.json(result);
} catch (err) {
logger.error('Failed to run Gitea setup', err);
next(err);
}
},
);
export const giteaSetupRouter = router;

View File

@@ -0,0 +1,447 @@
import { env } from '../../config/env';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { encrypt } from '../../utils/crypto';
import { giteaClient } from '../../services/gitea.client';
const SETUP_TIMEOUT = 15000;
interface StepResult {
step: string;
success: boolean;
error?: string;
data?: Record<string, unknown>;
}
interface SetupResult {
success: boolean;
steps: StepResult[];
error?: string;
}
/**
* Make a Gitea API request with Basic Auth (for bootstrapping before we have a token).
*/
async function giteaBasicRequest<T>(
method: string,
path: string,
username: string,
password: string,
body?: Record<string, unknown>,
): Promise<T> {
const url = `${env.GITEA_URL}/api/v1${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SETUP_TIMEOUT);
const headers: Record<string, string> = {
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
};
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);
}
}
/**
* Make a Gitea API request with token auth.
*/
async function giteaTokenRequest<T>(
method: string,
path: string,
token: string,
body?: Record<string, unknown>,
): Promise<T> {
const url = `${env.GITEA_URL}/api/v1${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SETUP_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);
}
}
/**
* Check if Gitea is reachable and the install page has been completed.
*/
async function checkStatus(): Promise<{
giteaOnline: boolean;
installComplete: boolean;
tokenConfigured: boolean;
reposCreated: boolean;
oauthConfigured: boolean;
setupComplete: boolean;
}> {
let giteaOnline = false;
let installComplete = false;
let tokenConfigured = false;
let reposCreated = false;
let oauthConfigured = false;
// Check Gitea online
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${env.GITEA_URL}/api/v1/version`, {
signal: controller.signal,
redirect: 'manual',
});
giteaOnline = res.ok;
installComplete = res.ok; // If API responds, install is complete
} finally {
clearTimeout(timeout);
}
} catch {
// Not reachable
}
// Check DB settings
try {
const settings = await prisma.siteSettings.findFirst({
select: {
giteaApiToken: true,
giteaOauthClientId: true,
giteaOauthClientSecret: true,
giteaSetupComplete: true,
},
});
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,
};
}
}
} catch {
// DB not available
}
// Check repos if we have a token
if (tokenConfigured && giteaOnline) {
try {
const config = await giteaClient.getConfig();
if (config.apiToken) {
await giteaTokenRequest('GET', `/repos/admin/docs-comments`, config.apiToken);
await giteaTokenRequest('GET', `/repos/admin/changemaker.lite`, config.apiToken);
reposCreated = true;
}
} catch {
// Repos don't exist
}
}
return {
giteaOnline,
installComplete,
tokenConfigured,
reposCreated,
oauthConfigured,
setupComplete: tokenConfigured && reposCreated,
};
}
/**
* Test connection with basic auth credentials.
*/
async function testConnection(username: string, password: string): Promise<{
success: boolean;
giteaVersion?: string;
error?: string;
}> {
try {
const data = await giteaBasicRequest<{ version: string }>(
'GET', '/version', username, password,
);
// Also verify admin access
await giteaBasicRequest<{ login: string; is_admin: boolean }>(
'GET', '/user', username, password,
);
return { success: true, giteaVersion: data.version };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('401')) {
return { success: false, error: 'Invalid username or password' };
}
return { success: false, error: msg };
}
}
/**
* Run the full Gitea setup: create token, repos, labels, OAuth app.
*/
async function runFullSetup(username: string, password: string): Promise<SetupResult> {
const steps: StepResult[] = [];
// Step 1: Check Gitea is reachable
try {
await giteaBasicRequest<{ version: string }>('GET', '/version', username, password);
steps.push({ step: 'check_connection', success: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
steps.push({ step: 'check_connection', success: false, error: msg });
return { success: false, steps, error: 'Cannot connect to Gitea' };
}
// Step 2: Create API token
let apiToken = '';
try {
// First try to delete any existing token with the same name
try {
await giteaBasicRequest('DELETE', `/users/${username}/tokens/changemaker-auto`, username, password);
} catch {
// Token doesn't exist, that's fine
}
const tokenData = await giteaBasicRequest<{ sha1: string }>(
'POST',
`/users/${username}/tokens`,
username,
password,
{ name: 'changemaker-auto', scopes: ['all'] },
);
apiToken = tokenData.sha1;
steps.push({ step: 'create_token', success: true, data: { tokenName: 'changemaker-auto' } });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
steps.push({ step: 'create_token', success: false, error: msg });
return { success: false, steps, error: 'Failed to create API token' };
}
// Step 3: Create docs-comments repo
try {
try {
await giteaTokenRequest('GET', `/repos/${username}/docs-comments`, apiToken);
steps.push({ step: 'create_comments_repo', success: true, data: { note: 'Already exists' } });
} catch {
await giteaTokenRequest('POST', '/user/repos', apiToken, {
name: 'docs-comments',
description: 'Documentation page comments — managed by Changemaker Lite',
private: false,
auto_init: true,
});
steps.push({ step: 'create_comments_repo', success: true });
}
} catch (err) {
steps.push({ step: 'create_comments_repo', success: false, error: err instanceof Error ? err.message : String(err) });
}
// Step 4: Create changemaker.lite repo (for version history)
try {
try {
await giteaTokenRequest('GET', `/repos/${username}/changemaker.lite`, apiToken);
steps.push({ step: 'create_history_repo', success: true, data: { note: 'Already exists' } });
} catch {
await giteaTokenRequest('POST', '/user/repos', apiToken, {
name: 'changemaker.lite',
description: 'Documentation version history — managed by Changemaker Lite',
private: true,
auto_init: true,
default_branch: 'v2',
});
steps.push({ step: 'create_history_repo', success: true });
}
} catch (err) {
steps.push({ step: 'create_history_repo', success: false, error: err instanceof Error ? err.message : String(err) });
}
// Step 5: Create labels on docs-comments repo
try {
const labels = [
{ name: 'docs-page', color: '#0075ca' },
{ name: 'anonymous', color: '#e4e669' },
{ name: 'moderated', color: '#0e8a16' },
];
for (const label of labels) {
try {
await giteaTokenRequest('POST', `/repos/${username}/docs-comments/labels`, apiToken, label);
} catch {
// Label may already exist
}
}
steps.push({ step: 'create_labels', success: true });
} catch (err) {
steps.push({ step: 'create_labels', success: false, error: err instanceof Error ? err.message : String(err) });
}
// Step 6: Create OAuth2 app
let oauthClientId = '';
let oauthClientSecret = '';
try {
// Check if OAuth app already exists
const existingApps = await giteaTokenRequest<Array<{ id: number; name: string; client_id: string; client_secret: string }>>(
'GET', '/user/applications/oauth2', apiToken,
);
const existing = existingApps.find(a => a.name === 'changemaker-docs');
if (existing) {
oauthClientId = existing.client_id;
// Can't retrieve secret for existing app; leave blank if already set in DB
steps.push({ step: 'create_oauth_app', success: true, data: { note: 'Already exists', clientId: oauthClientId } });
} else {
const domain = env.DOMAIN || 'cmlite.org';
const oauthData = await giteaTokenRequest<{ client_id: string; client_secret: string }>(
'POST', '/user/applications/oauth2', apiToken,
{
name: 'changemaker-docs',
redirect_uris: [
`https://${domain}/comments/callback/`,
`https://docs.${domain}/comments/callback/`,
`http://localhost:${env.MKDOCS_PORT || 4003}/comments/callback/`,
],
},
);
oauthClientId = oauthData.client_id;
oauthClientSecret = oauthData.client_secret;
steps.push({ step: 'create_oauth_app', success: true, data: { clientId: oauthClientId } });
}
} catch (err) {
steps.push({ step: 'create_oauth_app', success: false, error: err instanceof Error ? err.message : String(err) });
}
// Step 7: Save to database
try {
const updateData: Record<string, unknown> = {
enableDocsComments: true,
giteaApiToken: encrypt(apiToken),
giteaCommentsRepoOwner: username,
giteaCommentsRepoName: 'docs-comments',
giteaSetupComplete: true,
};
if (oauthClientId) updateData.giteaOauthClientId = oauthClientId;
if (oauthClientSecret) updateData.giteaOauthClientSecret = encrypt(oauthClientSecret);
await prisma.siteSettings.updateMany({ data: updateData });
// Clear config cache so the running system picks up new credentials
giteaClient.clearConfigCache();
steps.push({ step: 'save_config', success: true });
} catch (err) {
steps.push({ step: 'save_config', success: false, error: err instanceof Error ? err.message : String(err) });
return { success: false, steps, error: 'Failed to save configuration' };
}
const allStepsSucceeded = steps.every(s => s.success);
return { success: allStepsSucceeded, steps };
}
/**
* Auto-setup if GITEA_ADMIN_PASSWORD is set and setup hasn't been completed.
* Called on API startup. Fire-and-forget with retries.
*/
async function autoSetupIfNeeded(): Promise<{ alreadyComplete: boolean; success: boolean; error?: string }> {
const password = env.GITEA_ADMIN_PASSWORD;
if (!password) return { alreadyComplete: true, success: true };
// Check if already complete
try {
const settings = await prisma.siteSettings.findFirst({
select: { giteaSetupComplete: true },
});
if (settings?.giteaSetupComplete) {
return { alreadyComplete: true, success: true };
}
} catch {
// DB might not be ready yet
}
// Wait for Gitea to be available (up to 3 retries, 15s apart)
let giteaReady = false;
for (let i = 0; i < 3; i++) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${env.GITEA_URL}/api/v1/version`, { signal: controller.signal });
if (res.ok) {
giteaReady = true;
break;
}
} finally {
clearTimeout(timeout);
}
} catch {
// Not ready yet
}
if (i < 2) {
logger.info(`Gitea auto-setup: waiting for Gitea to be ready (attempt ${i + 1}/3)...`);
await new Promise(r => setTimeout(r, 15000));
}
}
if (!giteaReady) {
return { alreadyComplete: false, success: false, error: 'Gitea not reachable after 3 attempts' };
}
// Run setup
logger.info('Gitea auto-setup: running initial setup...');
const result = await runFullSetup('admin', password);
if (result.success) {
logger.info('Gitea auto-setup: completed successfully');
for (const step of result.steps) {
logger.info(` ${step.step}: ${step.success ? 'OK' : 'FAILED'}${step.data?.note ? ` (${step.data.note})` : ''}`);
}
} else {
logger.warn(`Gitea auto-setup: failed — ${result.error}`);
for (const step of result.steps) {
if (!step.success) logger.warn(` ${step.step}: ${step.error}`);
}
}
return { alreadyComplete: false, success: result.success, error: result.error };
}
export const giteaSetupService = {
checkStatus,
testConnection,
runFullSetup,
autoSetupIfNeeded,
};

View File

@@ -77,6 +77,7 @@ export const updateSiteSettingsSchema = z.object({
giteaCommentsRepoName: z.string().max(100).optional(),
giteaOauthClientId: z.string().max(500).optional(),
giteaOauthClientSecret: z.string().max(500).optional(),
giteaSetupComplete: z.boolean().optional(),
// User Provisioning
enableUserProvisioning: z.boolean().optional(),

View File

@@ -38,6 +38,9 @@ import { pagesPublicRouter } from './modules/pages/pages-public.routes';
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
import { blocksRouter } from './modules/pages/blocks.routes';
import { docsRouter } from './modules/docs/docs.routes';
import { docsAccessRouter } from './modules/docs/docs-access.routes';
import { giteaSetupRouter } from './modules/gitea-setup/gitea-setup.routes';
import { giteaSetupService } from './modules/gitea-setup/gitea-setup.service';
import { servicesRouter } from './modules/services/services.routes';
import { siteSettingsRouter } from './modules/settings/settings.routes';
import { canvassVolunteerRouter, canvassAdminRouter } from './modules/map/canvass/canvass.routes';
@@ -308,6 +311,8 @@ app.use('/api/pages', pagesPublicRouter); // Public landing pages
app.use('/api/pages', pagesAdminRouter); // Admin landing page CRUD (auth required)
app.use('/api/page-blocks', blocksRouter); // Admin page block library (auth required)
app.use('/api/docs', docsRouter); // Docs status + config (auth required)
app.use('/api/docs-access', docsAccessRouter); // Docs access policies + share links
app.use('/api/gitea/setup', giteaSetupRouter); // Gitea auto-setup (SUPER_ADMIN)
app.use('/api/services', servicesRouter); // Platform services status (SUPER_ADMIN)
app.use('/api/map/canvass', canvassVolunteerRouter); // Volunteer canvass routes (auth required)
app.use('/api/map/canvass', canvassAdminRouter); // Admin canvass routes (MAP_ADMIN+)
@@ -421,6 +426,9 @@ async function start() {
reengagementService.scan().catch(() => {});
socialDigestService.scan().catch(() => {});
// Gitea auto-setup (if admin password is provided, auto-configure token + repos)
giteaSetupService.autoSetupIfNeeded().catch(() => {});
// SSE + Presence: mark all users offline on startup, start heartbeat + stale cleanup
presenceService.markAllOffline().catch(() => {});
sseService.startHeartbeat();