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:
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "site_settings" ADD COLUMN "gitea_setup_complete" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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'),
|
||||
|
||||
51
api/src/modules/docs/blog.schemas.ts
Normal file
51
api/src/modules/docs/blog.schemas.ts
Normal 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>;
|
||||
198
api/src/modules/docs/blog.service.ts
Normal file
198
api/src/modules/docs/blog.service.ts
Normal 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,
|
||||
};
|
||||
297
api/src/modules/docs/docs-access.routes.ts
Normal file
297
api/src/modules/docs/docs-access.routes.ts
Normal 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;
|
||||
23
api/src/modules/docs/docs-access.schemas.ts
Normal file
23
api/src/modules/docs/docs-access.schemas.ts
Normal 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>;
|
||||
286
api/src/modules/docs/docs-access.service.ts
Normal file
286
api/src/modules/docs/docs-access.service.ts
Normal 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,
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
267
api/src/modules/docs/docs-history.service.ts
Normal file
267
api/src/modules/docs/docs-history.service.ts
Normal 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,
|
||||
};
|
||||
173
api/src/modules/docs/docs-metadata.service.ts
Normal file
173
api/src/modules/docs/docs-metadata.service.ts
Normal 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,
|
||||
};
|
||||
404
api/src/modules/docs/docs-templates.service.ts
Normal file
404
api/src/modules/docs/docs-templates.service.ts
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
70
api/src/modules/gitea-setup/gitea-setup.routes.ts
Normal file
70
api/src/modules/gitea-setup/gitea-setup.routes.ts
Normal 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;
|
||||
447
api/src/modules/gitea-setup/gitea-setup.service.ts
Normal file
447
api/src/modules/gitea-setup/gitea-setup.service.ts
Normal 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,
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user