Files
2026-04-12 01:06:31 +07:00

435 lines
11 KiB
JavaScript

/**
* Core HTTP server for markdown-novel-viewer
* Handles routing for markdown viewer and directory browser
*
* Routes:
* - /view?file=<path> - Markdown file viewer
* - /browse?dir=<path> - 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', `
<!DOCTYPE html>
<html>
<head><title>Error ${statusCode}</title></head>
<body style="font-family: system-ui; padding: 2rem;">
<h1>Error ${statusCode}</h1>
<p>${safeMessage}</p>
</body>
</html>
`);
}
/**
* 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 += `<li class="dir-item parent">
<a href="/browse?dir=${encodeURIComponent(parentDir)}">
<span class="icon">📁</span>
<span class="name">..</span>
</a>
</li>`;
}
// Directories
for (const dir of dirs) {
const fullPath = path.join(dirPath, dir);
listHtml += `<li class="dir-item folder">
<a href="/browse?dir=${encodeURIComponent(fullPath)}">
<span class="icon">📁</span>
<span class="name">${dir}/</span>
</a>
</li>`;
}
// Files
for (const file of files) {
const fullPath = path.join(dirPath, file);
const icon = getFileIcon(file);
const isMarkdown = file.endsWith('.md');
if (isMarkdown) {
listHtml += `<li class="dir-item file markdown">
<a href="/view?file=${encodeURIComponent(fullPath)}">
<span class="icon">${icon}</span>
<span class="name">${file}</span>
</a>
</li>`;
} else {
listHtml += `<li class="dir-item file">
<a href="/file${fullPath}" target="_blank">
<span class="icon">${icon}</span>
<span class="name">${file}</span>
</a>
</li>`;
}
}
// Empty directory message
if (dirs.length === 0 && files.length === 0) {
listHtml = '<li class="empty">This directory is empty</li>';
}
// Read CSS
let css = '';
const cssPath = path.join(assetsDir, 'directory-browser.css');
if (fs.existsSync(cssPath)) {
css = fs.readFileSync(cssPath, 'utf8');
}
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📁 ${path.basename(dirPath)}</title>
<style>
${css}
</style>
</head>
<body>
<div class="container">
<header>
<h1>📁 ${path.basename(dirPath)}</h1>
<p class="path">${displayPath}</p>
</header>
<ul class="file-list">
${listHtml}
</ul>
<footer>
<p>${dirs.length} folder${dirs.length !== 1 ? 's' : ''}, ${files.length} file${files.length !== 1 ? 's' : ''}</p>
</footer>
</div>
</body>
</html>`;
}
/**
* 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=<path> - 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=<path> - 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', `
<!DOCTYPE html>
<html>
<head>
<title>Markdown Novel Viewer</title>
<style>
body { font-family: system-ui; max-width: 600px; margin: 2rem auto; padding: 1rem; }
h1 { color: #8b4513; }
code { background: #f5f5f5; padding: 0.2rem 0.4rem; border-radius: 3px; }
.routes { background: #faf8f3; padding: 1rem; border-radius: 8px; margin: 1rem 0; }
</style>
</head>
<body>
<h1>📖 Markdown Novel Viewer</h1>
<p>A calm, book-like viewer for markdown files.</p>
<div class="routes">
<h3>Routes</h3>
<ul>
<li><code>/view?file=/path/to/file.md</code> - View markdown</li>
<li><code>/browse?dir=/path/to/dir</code> - Browse directory</li>
</ul>
</div>
<p>Use the <code>/ck:preview</code> skill invocation to start viewing files.</p>
</body>
</html>
`);
return;
}
// Default: 404
sendError(res, 404, 'Not found');
});
return server;
}
module.exports = {
createHttpServer,
getMimeType,
sendResponse,
sendError,
serveFile,
isPathSafe,
setAllowedDirs,
sanitizeErrorMessage,
MIME_TYPES,
renderDirectoryBrowser,
getFileIcon
};