363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
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<string, string | string[]>;
|
|
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;
|