// ============================================================ // ADMIN DOCS ROUTE — serves the project docs/ folder as a // browseable, admin-only site inside the app. // // Endpoints (both require req.user.role === 'admin'): // GET /api/admin/docs/tree — returns nested JSON of docs/ // GET /api/admin/docs/file?path=X — returns rendered HTML for one .md // // Path safety: every requested path is normalised + forced to live // under /app/docs (or wherever DOCS_ROOT resolves). Any attempt to // traverse out (../, absolute paths, symlinks pointing elsewhere) is // rejected with 400. // ============================================================ var express = require('express'); var router = express.Router(); var fs = require('fs'); var path = require('path'); var { authMiddleware } = require('../middleware/auth'); var logger = require('../utils/logger'); var { marked } = require('marked'); router.use(authMiddleware); // Admin gate. Returns 403 with a generic message — does not leak // whether the path exists or not. function requireAdmin(req, res, next) { if (!req.user || req.user.role !== 'admin') { return res.status(403).json({ error: 'Admin only' }); } next(); } // Resolve the docs folder relative to the project root. server.js lives // at the repo root and __dirname here is src/routes, so go up two. var DOCS_ROOT = path.resolve(__dirname, '..', '..', 'docs'); // Resolve a user-supplied path safely. Returns null if the path tries // to escape DOCS_ROOT or doesn't exist. function safeResolve(relPath) { if (typeof relPath !== 'string' || !relPath) return null; // Strip any leading slashes so path.join can't be tricked into an // absolute path on POSIX. var trimmed = relPath.replace(/^[\/\\]+/, ''); var abs = path.resolve(DOCS_ROOT, trimmed); // Must stay inside DOCS_ROOT after resolution (defends against // ../ traversal and on Windows backslash variants). if (abs !== DOCS_ROOT && !abs.startsWith(DOCS_ROOT + path.sep)) return null; if (!fs.existsSync(abs)) return null; return abs; } // Recursively walk DOCS_ROOT into a JSON tree the client can render // as a sidebar. Skips dotfiles and non-.md files inside leaf nodes. function buildTree(dir, baseRel) { var entries = fs.readdirSync(dir, { withFileTypes: true }) .filter(function (d) { return !d.name.startsWith('.'); }) .sort(function (a, b) { // README always first inside its folder so the index is the obvious landing. if (a.name.toLowerCase() === 'readme.md') return -1; if (b.name.toLowerCase() === 'readme.md') return 1; // Folders before files within the same level. if (a.isDirectory() && !b.isDirectory()) return -1; if (!a.isDirectory() && b.isDirectory()) return 1; return a.name.localeCompare(b.name); }); var out = []; entries.forEach(function (e) { var rel = baseRel ? (baseRel + '/' + e.name) : e.name; if (e.isDirectory()) { out.push({ type: 'dir', name: e.name, path: rel, children: buildTree(path.join(dir, e.name), rel) }); } else if (/\.md$/i.test(e.name)) { out.push({ type: 'file', name: e.name, path: rel }); } }); return out; } // ── GET /tree (mounted at /api/admin/docs) ───────────────────────── router.get('/tree', requireAdmin, function (req, res) { try { if (!fs.existsSync(DOCS_ROOT)) { return res.json({ success: true, tree: [], root: 'docs' }); } var tree = buildTree(DOCS_ROOT, ''); res.json({ success: true, tree: tree, root: 'docs' }); } catch (e) { logger.error('[adminDocs] tree failed', e.message); res.status(500).json({ error: 'Tree build failed' }); } }); // ── GET /file?path=X (mounted at /api/admin/docs) ────────────────── router.get('/file', requireAdmin, function (req, res) { try { var raw = req.query.path; var abs = safeResolve(raw); if (!abs) return res.status(400).json({ error: 'Invalid path' }); var stat = fs.statSync(abs); if (!stat.isFile()) return res.status(400).json({ error: 'Not a file' }); if (!/\.md$/i.test(abs)) return res.status(400).json({ error: 'Only .md files served' }); var src = fs.readFileSync(abs, 'utf8'); // marked: GitHub-flavoured rendering with automatic line breaks off // (so authors can wrap prose without inserting
everywhere) and // header IDs on so the client can deep-link via #anchor. var html = marked.parse(src, { gfm: true, breaks: false, headerIds: true }); res.json({ success: true, html: html, path: raw, bytes: stat.size }); } catch (e) { logger.error('[adminDocs] file failed', e.message); res.status(500).json({ error: 'Read failed' }); } }); module.exports = router;