/** * Core HTTP server for markdown-novel-viewer * Handles routing for markdown viewer and directory browser * * Routes: * - /view?file= - Markdown file viewer * - /browse?dir= - Directory browser * - /assets/* - Static assets * - /file/* - Local files (images, etc.) * * Security: Paths are validated to prevent directory traversal attacks */ const http = require('http'); const fs = require('fs'); const path = require('path'); const url = require('url'); // Allowed base directories for file access (set at runtime) let allowedBaseDirs = []; /** * Set allowed directories for file serving * @param {string[]} dirs - Array of allowed directory paths */ function setAllowedDirs(dirs) { allowedBaseDirs = dirs.map(d => path.resolve(d)); } /** * Validate path is within allowed directories (prevents path traversal) * @param {string} filePath - Path to validate * @param {string[]} allowedDirs - Allowed base directories * @returns {boolean} - True if path is safe */ function isPathSafe(filePath, allowedDirs = allowedBaseDirs) { const resolved = path.resolve(filePath); // Check for path traversal attempts if (resolved.includes('..') || filePath.includes('\0')) { return false; } // If no allowed dirs set, allow only project paths if (allowedDirs.length === 0) { return true; } // Must be within one of the allowed directories return allowedDirs.some(dir => resolved.startsWith(dir)); } /** * Sanitize error message to prevent path disclosure */ function sanitizeErrorMessage(message) { return message.replace(/\/[^\s'"<>]+/g, '[path]'); } // MIME type mapping const MIME_TYPES = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.ico': 'image/x-icon', '.md': 'text/markdown', '.txt': 'text/plain', '.pdf': 'application/pdf' }; /** * Get MIME type for file extension */ function getMimeType(filePath) { const ext = path.extname(filePath).toLowerCase(); return MIME_TYPES[ext] || 'application/octet-stream'; } /** * Send response with content */ function sendResponse(res, statusCode, contentType, content) { res.writeHead(statusCode, { 'Content-Type': contentType }); res.end(content); } /** * Send error response (sanitized) */ function sendError(res, statusCode, message) { const safeMessage = sanitizeErrorMessage(message); sendResponse(res, statusCode, 'text/html', ` Error ${statusCode}

Error ${statusCode}

${safeMessage}

`); } /** * Serve static file with path validation */ 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); const mimeType = getMimeType(filePath); sendResponse(res, 200, mimeType, content); } /** * Get file icon based on extension */ function getFileIcon(filename) { const ext = path.extname(filename).toLowerCase(); const iconMap = { '.md': '📄', '.txt': '📝', '.json': '📋', '.js': '📜', '.cjs': '📜', '.mjs': '📜', '.ts': '📘', '.css': '🎨', '.html': '🌐', '.png': '🖼️', '.jpg': '🖼️', '.jpeg': '🖼️', '.gif': '🖼️', '.svg': '🖼️', '.pdf': '📕', '.yaml': '⚙️', '.yml': '⚙️', '.toml': '⚙️', '.env': '🔐', '.sh': '💻', '.bash': '💻' }; return iconMap[ext] || '📄'; } /** * Render directory browser HTML */ function renderDirectoryBrowser(dirPath, assetsDir) { const items = fs.readdirSync(dirPath); const displayPath = dirPath.length > 50 ? '...' + dirPath.slice(-47) : dirPath; // Separate directories and files, sort alphabetically const dirs = []; const files = []; for (const item of items) { // Skip hidden files and deprecated folders if (item.startsWith('.') || item === 'deprecated') continue; const itemPath = path.join(dirPath, item); try { const stats = fs.statSync(itemPath); if (stats.isDirectory()) { dirs.push(item); } else { files.push(item); } } catch { // Skip items we can't stat } } dirs.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); files.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); // Build file list HTML let listHtml = ''; // Parent directory link (if not root) const parentDir = path.dirname(dirPath); if (parentDir !== dirPath) { listHtml += `
  • 📁 ..
  • `; } // Directories for (const dir of dirs) { const fullPath = path.join(dirPath, dir); listHtml += `
  • 📁 ${dir}/
  • `; } // Files for (const file of files) { const fullPath = path.join(dirPath, file); const icon = getFileIcon(file); const isMarkdown = file.endsWith('.md'); if (isMarkdown) { listHtml += `
  • ${icon} ${file}
  • `; } else { listHtml += `
  • ${icon} ${file}
  • `; } } // Empty directory message if (dirs.length === 0 && files.length === 0) { listHtml = '
  • This directory is empty
  • '; } // Read CSS let css = ''; const cssPath = path.join(assetsDir, 'directory-browser.css'); if (fs.existsSync(cssPath)) { css = fs.readFileSync(cssPath, 'utf8'); } return ` 📁 ${path.basename(dirPath)}

    📁 ${path.basename(dirPath)}

    ${displayPath}

      ${listHtml}

    ${dirs.length} folder${dirs.length !== 1 ? 's' : ''}, ${files.length} file${files.length !== 1 ? 's' : ''}

    `; } /** * Create HTTP server with routing * @param {Object} options - Server options * @param {string} options.assetsDir - Static assets directory * @param {Function} options.renderMarkdown - Markdown render function (filePath) => html * @param {string[]} options.allowedDirs - Allowed directories for file access * @returns {http.Server} - HTTP server instance */ function createHttpServer(options) { const { assetsDir, renderMarkdown, allowedDirs = [] } = options; // Set allowed directories for path validation 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 from assets directory if (pathname.startsWith('/assets/')) { const relativePath = pathname.replace('/assets/', ''); if (relativePath.includes('..')) { sendError(res, 403, 'Access denied'); return; } const assetPath = path.join(assetsDir, relativePath); serveFile(res, assetPath, true); return; } // Route: /file/* - serve local files (images, etc.) if (pathname.startsWith('/file/')) { // Extract path after '/file/' prefix (slice(6) removes '/file/') // Path is already URL-decoded by decodeURIComponent above const filePath = pathname.slice(6); if (!isPathSafe(filePath)) { sendError(res, 403, 'Access denied'); return; } serveFile(res, filePath); return; } // Route: /view?file= - render markdown (query param) if (pathname === '/view') { const filePath = parsedUrl.query?.file; if (!filePath) { sendError(res, 400, 'Missing ?file= parameter. Use /view?file=/path/to/file.md'); return; } if (!isPathSafe(filePath)) { sendError(res, 403, 'Access denied'); return; } if (!fs.existsSync(filePath)) { sendError(res, 404, 'File not found'); return; } try { const html = renderMarkdown(filePath); sendResponse(res, 200, 'text/html', html); } catch (err) { console.error('[http-server] Render error:', err.message); sendError(res, 500, 'Error rendering markdown'); } return; } // Route: /browse?dir= - directory browser (query param) if (pathname === '/browse') { const dirPath = parsedUrl.query?.dir; if (!dirPath) { sendError(res, 400, 'Missing ?dir= parameter. Use /browse?dir=/path/to/directory'); return; } if (!isPathSafe(dirPath)) { sendError(res, 403, 'Access denied'); return; } if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { sendError(res, 404, 'Directory not found'); return; } try { const html = renderDirectoryBrowser(dirPath, assetsDir); sendResponse(res, 200, 'text/html', html); } catch (err) { console.error('[http-server] Browse error:', err.message); sendError(res, 500, 'Error listing directory'); } return; } // Route: / - show welcome/usage page if (pathname === '/') { sendResponse(res, 200, 'text/html', ` Markdown Novel Viewer

    📖 Markdown Novel Viewer

    A calm, book-like viewer for markdown files.

    Routes

    • /view?file=/path/to/file.md - View markdown
    • /browse?dir=/path/to/dir - Browse directory

    Use the /ck:preview skill invocation to start viewing files.

    `); return; } // Default: 404 sendError(res, 404, 'Not found'); }); return server; } module.exports = { createHttpServer, getMimeType, sendResponse, sendError, serveFile, isPathSafe, setAllowedDirs, sanitizeErrorMessage, MIME_TYPES, renderDirectoryBrowser, getFileIcon };