import { Router, Request, Response, NextFunction } from 'express'; import multer from 'multer'; import { rm } from 'fs/promises'; import { extname, basename } from 'path'; import { authenticate } from '../../middleware/auth.middleware'; import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware'; import { env } from '../../config/env'; import { logger } from '../../utils/logger'; import { isServiceOnline } from '../../utils/health-check'; import { cm_docs_operations } from '../../utils/metrics'; import { docsFilesService, PathTraversalError, FileNotFoundError } from './docs-files.service'; import { mkdocsConfigService } from './mkdocs-config.service'; import { headerBuilderService } from './header-builder.service'; import { headerConfigSchema } from './header-builder.schemas'; const router = Router(); router.use(authenticate); router.use(requireNonTemp); // Removed duplicated isServiceOnline - now using shared utility from utils/health-check.ts // GET /api/docs/status — check MkDocs and Code Server availability router.get( '/status', async (_req: Request, res: Response, next: NextFunction) => { try { const [mkdocsOnline, codeServerOnline, siteServerOnline] = await Promise.all([ isServiceOnline(env.MKDOCS_PREVIEW_URL), isServiceOnline(env.CODE_SERVER_URL), isServiceOnline(env.MKDOCS_SITE_SERVER_URL), ]); res.json({ mkdocs: { online: mkdocsOnline, url: env.MKDOCS_PREVIEW_URL }, codeServer: { online: codeServerOnline, url: env.CODE_SERVER_URL }, siteServer: { online: siteServerOnline, url: env.MKDOCS_SITE_SERVER_URL }, }); } catch (err) { logger.error('Failed to check docs status', err); next(err); } }, ); // GET /api/docs/config — return public-facing port numbers for iframe URLs router.get( '/config', async (_req: Request, res: Response, _next: NextFunction) => { res.json({ codeServerPort: env.CODE_SERVER_PORT, mkdocsPort: env.MKDOCS_PORT, mkdocsSitePort: env.MKDOCS_SITE_SERVER_PORT, }); }, ); // --- MkDocs Config Endpoints --- // GET /api/docs/mkdocs-config — read raw mkdocs.yml content router.get( '/mkdocs-config', async (_req: Request, res: Response, next: NextFunction) => { try { const content = await mkdocsConfigService.readConfig(); res.json({ content }); } catch (err) { logger.error('Failed to read mkdocs config', err); next(err); } }, ); // PUT /api/docs/mkdocs-config — validate + write mkdocs.yml (SUPER_ADMIN only) router.put( '/mkdocs-config', requireRole('SUPER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { try { const { content } = req.body as { content?: string }; if (typeof content !== 'string') { res.status(400).json({ error: { message: 'Content string required', code: 'VALIDATION_ERROR' } }); return; } await mkdocsConfigService.writeConfig(content); res.json({ success: true }); } catch (err) { if ((err as Error).message?.startsWith('Invalid YAML')) { res.status(400).json({ error: { message: (err as Error).message, code: 'VALIDATION_ERROR' } }); return; } logger.error('Failed to write mkdocs config', err); next(err); } }, ); // POST /api/docs/build — trigger mkdocs build in container (SUPER_ADMIN only) router.post( '/build', requireRole('SUPER_ADMIN'), async (_req: Request, res: Response, next: NextFunction) => { try { const result = await mkdocsConfigService.triggerBuild(); res.json(result); } catch (err) { logger.error('MkDocs build failed', err); next(err); } }, ); // --- Header Builder --- // GET /api/docs/header-config — read header nav bar config router.get( '/header-config', async (_req: Request, res: Response, next: NextFunction) => { try { const config = await headerBuilderService.readConfig(); res.json(config); } catch (err) { logger.error('Failed to read header config', err); next(err); } }, ); // PUT /api/docs/header-config — save header nav bar config + regenerate template router.put( '/header-config', requireRole('SUPER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { try { const parsed = headerConfigSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: { message: 'Invalid header config', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors }, }); return; } await headerBuilderService.writeConfig(parsed.data); // Invalidate docs file tree cache so the new main.html shows up await docsFilesService.invalidateTreeCache(); res.json({ success: true }); } catch (err) { logger.error('Failed to save header config', err); next(err); } }, ); // --- File Upload --- const ALLOWED_UPLOAD_EXTENSIONS = new Set([ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.pdf', '.zip', ]); const upload = multer({ storage: multer.diskStorage({}), // temp dir limits: { fileSize: 20 * 1024 * 1024 }, // 20MB fileFilter: (_req, file, cb) => { const ext = extname(file.originalname).toLowerCase(); if (ALLOWED_UPLOAD_EXTENSIONS.has(ext)) { cb(null, true); } else { cb(new Error(`File type not allowed: ${ext}`)); } }, }); // POST /api/docs/upload — upload binary file (image, pdf, etc.) router.post( '/upload', requireRole('SUPER_ADMIN'), upload.single('file'), async (req: Request, res: Response, next: NextFunction) => { const tempPath = req.file?.path; try { cm_docs_operations.inc({ operation: 'upload' }); if (!req.file) { res.status(400).json({ error: { message: 'No file provided', code: 'VALIDATION_ERROR' } }); return; } const targetDir = (req.body as { path?: string }).path || ''; const fileName = basename(req.file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_'); const relativePath = targetDir ? `${targetDir}/${fileName}` : fileName; await docsFilesService.uploadFile(relativePath, req.file.path); // Clean up temp file try { await rm(req.file.path); } catch { /* ignore */ } res.json({ success: true, path: relativePath }); } catch (err) { // Clean up temp file on error if (tempPath) { try { await rm(tempPath); } catch { /* ignore */ } } handleFileError(err, res, next); } }, ); // --- File Management Endpoints --- // GET /api/docs/files — list file tree router.get( '/files', async (req: Request, res: Response, next: NextFunction) => { try { cm_docs_operations.inc({ operation: 'list' }); if (req.query['force'] === 'true') { await docsFilesService.invalidateTreeCache(); } const tree = await docsFilesService.listTree(); res.json(tree); } catch (err) { logger.error('Failed to list docs files', err); next(err); } }, ); // POST /api/docs/files/rename — rename/move file router.post( '/files/rename', requireRole('SUPER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { try { cm_docs_operations.inc({ operation: 'rename' }); const { from, to } = req.body as { from?: string; to?: string }; if (!from || !to) { res.status(400).json({ error: { message: 'Both "from" and "to" paths are required', code: 'VALIDATION_ERROR' } }); return; } await docsFilesService.renameFile(from, to); res.json({ success: true }); } catch (err) { handleFileError(err, res, next); } }, ); // GET /api/docs/files/* — read file content router.get( '/files/*', async (req: Request, res: Response, next: NextFunction) => { try { cm_docs_operations.inc({ operation: 'read' }); const filePath = extractWildcardPath(req); if (!filePath) { res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } }); return; } const content = await docsFilesService.readFileContent(filePath); res.json({ path: filePath, content }); } catch (err) { handleFileError(err, res, next); } }, ); // PUT /api/docs/files/* — write/update file content router.put( '/files/*', requireRole('SUPER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { try { cm_docs_operations.inc({ operation: 'write' }); const filePath = extractWildcardPath(req); if (!filePath) { res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } }); return; } const { content } = req.body as { content?: string }; if (typeof content !== 'string') { res.status(400).json({ error: { message: 'Content string required', code: 'VALIDATION_ERROR' } }); return; } await docsFilesService.writeFileContent(filePath, content); res.json({ success: true, path: filePath }); } catch (err) { handleFileError(err, res, next); } }, ); // POST /api/docs/files/* — create new file or folder router.post( '/files/*', requireRole('SUPER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { try { cm_docs_operations.inc({ operation: 'create' }); const filePath = extractWildcardPath(req); if (!filePath) { res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } }); return; } const { content, isDirectory } = req.body as { content?: string; isDirectory?: boolean }; await docsFilesService.createFile(filePath, content, isDirectory); res.status(201).json({ success: true, path: filePath }); } catch (err) { handleFileError(err, res, next); } }, ); // DELETE /api/docs/files/* — delete file or empty folder router.delete( '/files/*', requireRole('SUPER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { try { cm_docs_operations.inc({ operation: 'delete' }); const filePath = extractWildcardPath(req); if (!filePath) { res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } }); return; } await docsFilesService.deleteFile(filePath); res.json({ success: true }); } catch (err) { handleFileError(err, res, next); } }, ); /** * Extract the wildcard path from Express 5 req.params. * Express 5 uses params[0] for * routes. */ function extractWildcardPath(req: Request): string { // Express 5: req.params is array-like for * routes const params = req.params as Record; const wildcardParam = params[0] || params['0']; if (Array.isArray(wildcardParam)) return wildcardParam.join('/'); return (wildcardParam as string) || ''; } function handleFileError(err: unknown, res: Response, next: NextFunction): void { if (err instanceof PathTraversalError) { res.status(403).json({ error: { message: 'Path traversal not allowed', code: 'FORBIDDEN' } }); return; } if (err instanceof FileNotFoundError) { res.status(404).json({ error: { message: (err as Error).message, code: 'NOT_FOUND' } }); return; } if ((err as Error).message?.startsWith('Already exists')) { res.status(409).json({ error: { message: (err as Error).message, code: 'CONFLICT' } }); return; } if ((err as Error).message === 'Directory is not empty') { res.status(400).json({ error: { message: 'Directory is not empty', code: 'VALIDATION_ERROR' } }); return; } logger.error('Docs file operation failed', err); next(err); } export const docsRouter = router;