"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.docsRouter = void 0; const express_1 = require("express"); const multer_1 = __importDefault(require("multer")); const promises_1 = require("fs/promises"); const path_1 = require("path"); const auth_middleware_1 = require("../../middleware/auth.middleware"); const rbac_middleware_1 = require("../../middleware/rbac.middleware"); const env_1 = require("../../config/env"); const roles_1 = require("../../utils/roles"); const logger_1 = require("../../utils/logger"); const health_check_1 = require("../../utils/health-check"); const metrics_1 = require("../../utils/metrics"); const docs_files_service_1 = require("./docs-files.service"); const docs_collab_service_1 = require("./docs-collab.service"); const mkdocs_config_service_1 = require("./mkdocs-config.service"); const header_builder_service_1 = require("./header-builder.service"); const header_builder_schemas_1 = require("./header-builder.schemas"); const router = (0, express_1.Router)(); router.use(auth_middleware_1.authenticate); router.use(rbac_middleware_1.requireNonTemp); // Removed duplicated isServiceOnline - now using shared utility from utils/health-check.ts // GET /api/docs/status — check MkDocs and Code Server availability (content editors only) router.get('/status', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), async (_req, res, next) => { try { const [mkdocsOnline, codeServerOnline, siteServerOnline] = await Promise.all([ (0, health_check_1.isServiceOnline)(env_1.env.MKDOCS_PREVIEW_URL), (0, health_check_1.isServiceOnline)(env_1.env.CODE_SERVER_URL), (0, health_check_1.isServiceOnline)(env_1.env.MKDOCS_SITE_SERVER_URL), ]); res.json({ mkdocs: { online: mkdocsOnline, url: env_1.env.MKDOCS_PREVIEW_URL }, codeServer: { online: codeServerOnline, url: env_1.env.CODE_SERVER_URL }, siteServer: { online: siteServerOnline, url: env_1.env.MKDOCS_SITE_SERVER_URL }, }); } catch (err) { logger_1.logger.error('Failed to check docs status', err); next(err); } }); // GET /api/docs/config — return public-facing port numbers for iframe URLs (content editors only) router.get('/config', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), async (_req, res, _next) => { res.json({ codeServerPort: env_1.env.CODE_SERVER_PORT, mkdocsPort: env_1.env.MKDOCS_PORT, mkdocsSitePort: env_1.env.MKDOCS_SITE_SERVER_PORT, }); }); // --- MkDocs Config Endpoints --- // GET /api/docs/mkdocs-config — read raw mkdocs.yml content (content editors only) router.get('/mkdocs-config', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), async (_req, res, next) => { try { const content = await mkdocs_config_service_1.mkdocsConfigService.readConfig(); res.json({ content }); } catch (err) { logger_1.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', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), async (req, res, next) => { try { const { content } = req.body; if (typeof content !== 'string') { res.status(400).json({ error: { message: 'Content string required', code: 'VALIDATION_ERROR' } }); return; } await mkdocs_config_service_1.mkdocsConfigService.writeConfig(content); res.json({ success: true }); } catch (err) { if (err.message?.startsWith('Invalid YAML')) { res.status(400).json({ error: { message: err.message, code: 'VALIDATION_ERROR' } }); return; } logger_1.logger.error('Failed to write mkdocs config', err); next(err); } }); // POST /api/docs/build — trigger mkdocs build in container router.post('/build', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), async (_req, res, next) => { try { const result = await mkdocs_config_service_1.mkdocsConfigService.triggerBuild(); res.json(result); } catch (err) { logger_1.logger.error('MkDocs build failed', err); next(err); } }); // --- Header Builder --- // GET /api/docs/header-config — read header nav bar config (content editors only) router.get('/header-config', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), async (_req, res, next) => { try { const config = await header_builder_service_1.headerBuilderService.readConfig(); res.json(config); } catch (err) { logger_1.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', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), async (req, res, next) => { try { const parsed = header_builder_schemas_1.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 header_builder_service_1.headerBuilderService.writeConfig(parsed.data); // Invalidate docs file tree cache so the new main.html shows up await docs_files_service_1.docsFilesService.invalidateTreeCache(); res.json({ success: true }); } catch (err) { logger_1.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 = (0, multer_1.default)({ storage: multer_1.default.diskStorage({}), // temp dir limits: { fileSize: 20 * 1024 * 1024 }, // 20MB fileFilter: (_req, file, cb) => { const ext = (0, path_1.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', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), upload.single('file'), async (req, res, next) => { const tempPath = req.file?.path; try { metrics_1.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.path || ''; const fileName = (0, path_1.basename)(req.file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_'); const relativePath = targetDir ? `${targetDir}/${fileName}` : fileName; await docs_files_service_1.docsFilesService.uploadFile(relativePath, req.file.path); // Clean up temp file try { await (0, promises_1.rm)(req.file.path); } catch { /* ignore */ } res.json({ success: true, path: relativePath }); } catch (err) { // Clean up temp file on error if (tempPath) { try { await (0, promises_1.rm)(tempPath); } catch { /* ignore */ } } handleFileError(err, res, next); } }); // --- File Management Endpoints --- // GET /api/docs/files — list file tree (content editors only) router.get('/files', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), async (req, res, next) => { try { metrics_1.cm_docs_operations.inc({ operation: 'list' }); if (req.query['force'] === 'true') { await docs_files_service_1.docsFilesService.invalidateTreeCache(); } const tree = await docs_files_service_1.docsFilesService.listTree(); res.json(tree); } catch (err) { logger_1.logger.error('Failed to list docs files', err); next(err); } }); // GET /api/docs/files/search — search files by name/path (content editors only) router.get('/files/search', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), async (req, res, next) => { try { const search = String(req.query['search'] ?? req.query['q'] ?? '').trim(); if (!search) { res.json({ files: [] }); return; } const limit = Math.min(Math.max(Number(req.query['limit']) || 5, 1), 20); const files = await docs_files_service_1.docsFilesService.searchFiles(search, limit); res.json({ files }); } catch (err) { logger_1.logger.error('Failed to search docs files', err); next(err); } }); // POST /api/docs/files/rename — rename/move file router.post('/files/rename', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), async (req, res, next) => { try { metrics_1.cm_docs_operations.inc({ operation: 'rename' }); const { from, to } = req.body; if (!from || !to) { res.status(400).json({ error: { message: 'Both "from" and "to" paths are required', code: 'VALIDATION_ERROR' } }); return; } await docs_files_service_1.docsFilesService.renameFile(from, to); // Invalidate old path's collaboration state docs_collab_service_1.docsCollabService.invalidateDocument(from).catch(() => { }); res.json({ success: true }); } catch (err) { handleFileError(err, res, next); } }); // GET /api/docs/files/* — read file content (content editors only) router.get('/files/*', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), async (req, res, next) => { try { metrics_1.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 docs_files_service_1.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/*', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), async (req, res, next) => { try { metrics_1.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; if (typeof content !== 'string') { res.status(400).json({ error: { message: 'Content string required', code: 'VALIDATION_ERROR' } }); return; } await docs_files_service_1.docsFilesService.writeFileContent(filePath, content); // Invalidate collaboration state so next session starts fresh from disk docs_collab_service_1.docsCollabService.invalidateDocument(filePath).catch(() => { }); res.json({ success: true, path: filePath }); } catch (err) { handleFileError(err, res, next); } }); // POST /api/docs/files/* — create new file or folder router.post('/files/*', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), async (req, res, next) => { try { metrics_1.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; await docs_files_service_1.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/*', (0, rbac_middleware_1.requireRole)(...roles_1.CONTENT_ROLES), async (req, res, next) => { try { metrics_1.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 docs_files_service_1.docsFilesService.deleteFile(filePath); // Invalidate collaboration state for deleted file docs_collab_service_1.docsCollabService.invalidateDocument(filePath).catch(() => { }); 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) { // Express 5: req.params is array-like for * routes const params = req.params; const wildcardParam = params[0] || params['0']; if (Array.isArray(wildcardParam)) return wildcardParam.join('/'); return wildcardParam || ''; } function handleFileError(err, res, next) { if (err instanceof docs_files_service_1.PathTraversalError) { res.status(403).json({ error: { message: 'Path traversal not allowed', code: 'FORBIDDEN' } }); return; } if (err instanceof docs_files_service_1.FileNotFoundError) { res.status(404).json({ error: { message: err.message, code: 'NOT_FOUND' } }); return; } if (err.message?.startsWith('Already exists')) { res.status(409).json({ error: { message: err.message, code: 'CONFLICT' } }); return; } if (err.message === 'Directory is not empty') { res.status(400).json({ error: { message: 'Directory is not empty', code: 'VALIDATION_ERROR' } }); return; } logger_1.logger.error('Docs file operation failed', err); next(err); } exports.docsRouter = router; //# sourceMappingURL=docs.routes.js.map