/** * HTTP server for plans-kanban dashboard * Routes: /kanban, /api/plans, /assets/*, /file/* */ const http = require('http'); const fs = require('fs'); const path = require('path'); const url = require('url'); const { scanPlans } = require('./plan-scanner.cjs'); const { renderDashboard } = require('./dashboard-renderer.cjs'); // Import full page renderer from markdown-novel-viewer skill let generateFullPage = null; let mdViewerAssetsDir = null; try { const mdViewerDir = path.join(__dirname, '..', '..', '..', 'markdown-novel-viewer'); mdViewerAssetsDir = path.join(mdViewerDir, 'assets'); // We need to call the server's generateFullPage function // Since it's not exported, we'll create a minimal wrapper const { renderMarkdownFile, renderTOCHtml } = require(path.join(mdViewerDir, 'scripts', 'lib', 'markdown-renderer.cjs')); const { generateNavSidebar, generateNavFooter, detectPlan } = require(path.join(mdViewerDir, 'scripts', 'lib', 'plan-navigator.cjs')); generateFullPage = (filePath, options = {}) => { const { html, toc, frontmatter, title } = renderMarkdownFile(filePath); const tocHtml = renderTOCHtml(toc); const navSidebar = generateNavSidebar(filePath); const navFooter = generateNavFooter(filePath); const planInfo = detectPlan(filePath); const { getNavigationContext } = require(path.join(mdViewerDir, 'scripts', 'lib', 'plan-navigator.cjs')); const navContext = getNavigationContext(filePath); const templatePath = path.join(mdViewerAssetsDir, 'template.html'); let template = fs.readFileSync(templatePath, 'utf8'); // Generate back button for kanban const backUrl = options.dashboardUrl || '/kanban'; const backButton = ` `; // Generate header nav (prev/next) let headerNav = ''; if (navContext.prev || navContext.next) { const prevBtn = navContext.prev && fs.existsSync(navContext.prev.file) ? ` Prev ` : ''; const nextBtn = navContext.next && fs.existsSync(navContext.next.file) ? ` Next ` : ''; headerNav = `
${message}
`); } function serveFile(res, filePath, skipValidation = false) { if (!skipValidation && !isPathSafe(filePath)) { sendError(res, 403, 'Access denied'); return; } if (!fs.existsSync(filePath)) { sendError(res, 404, 'File not found'); return; } const content = fs.readFileSync(filePath); sendResponse(res, 200, getMimeType(filePath), content); } /** * Create HTTP server for kanban dashboard * @param {Object} options - Server options * @param {string} options.assetsDir - Static assets directory * @param {string[]} options.allowedDirs - Allowed directories for file access * @param {string} options.plansDir - Plans directory for dashboard * @returns {http.Server} */ function createHttpServer(options) { const { assetsDir, allowedDirs = [], plansDir } = options; if (allowedDirs.length > 0) { setAllowedDirs(allowedDirs); } const server = http.createServer((req, res) => { const parsedUrl = url.parse(req.url, true); const pathname = decodeURIComponent(parsedUrl.pathname); // Route: /assets/* - serve static files (check kanban assets first, then markdown-viewer assets) if (pathname.startsWith('/assets/')) { const relativePath = pathname.replace('/assets/', ''); if (relativePath.includes('..')) { sendError(res, 403, 'Access denied'); return; } // Check kanban assets first let assetPath = path.join(assetsDir, relativePath); if (!fs.existsSync(assetPath) && mdViewerAssetsDir) { // Fallback to markdown-novel-viewer assets assetPath = path.join(mdViewerAssetsDir, relativePath); } serveFile(res, assetPath, true); return; } // Route: /file/* - serve local files (images, etc.) if (pathname.startsWith('/file/')) { const filePath = pathname.replace('/file', ''); if (!isPathSafe(filePath)) { sendError(res, 403, 'Access denied'); return; } serveFile(res, filePath); return; } // Route: /api/plans - JSON API for plans data if (pathname === '/api/plans') { const customDir = parsedUrl.query?.dir; const dir = customDir || plansDir; if (customDir && !isPathSafe(customDir)) { sendError(res, 403, 'Access denied'); return; } if (!dir) { sendResponse(res, 200, 'application/json', JSON.stringify({ plans: [], error: 'Plans directory not configured' })); return; } try { const plans = scanPlans(dir); sendResponse(res, 200, 'application/json', JSON.stringify({ plans })); } catch (err) { console.error('[http-server] API error:', err.message); sendResponse(res, 500, 'application/json', JSON.stringify({ error: 'Error scanning plans' })); } return; } // Route: / or /kanban - render dashboard if (pathname === '/' || pathname === '/kanban') { const customDir = parsedUrl.query?.dir; const dir = customDir || plansDir; if (customDir && !isPathSafe(customDir)) { sendError(res, 403, 'Access denied'); return; } if (!dir) { sendError(res, 400, 'Plans directory not configured'); return; } try { const plans = scanPlans(dir); const html = renderDashboard(plans, { assetsDir, plansDir: dir }); sendResponse(res, 200, 'text/html', html); } catch (err) { console.error('[http-server] Dashboard error:', err.message); sendError(res, 500, 'Error rendering dashboard'); } return; } // Route: /view?file=