const fs = require("fs"); const http = require("http"); const path = require("path"); const { repoRoot } = require("./lib/meals"); const { defaultStatePath, loadRankingsState, recordVote, resetRankingsState, undoLastRankingsVote, } = require("./lib/rankings-state"); const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_PORT = 4321; const MAX_REQUEST_BODY_BYTES = 16 * 1024; const MIME_TYPES = { ".css": "text/css; charset=utf-8", ".gif": "image/gif", ".html": "text/html; charset=utf-8", ".ico": "image/x-icon", ".jpg": "image/jpeg", ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".map": "application/json; charset=utf-8", ".mp3": "audio/mpeg", ".png": "image/png", ".svg": "image/svg+xml", ".ttf": "font/ttf", ".txt": "text/plain; charset=utf-8", ".webmanifest": "application/manifest+json; charset=utf-8", ".woff": "font/woff", ".woff2": "font/woff2", }; function parseArgs(argv) { const options = { host: process.env.HOST || DEFAULT_HOST, port: Number.parseInt(process.env.PORT || String(DEFAULT_PORT), 10), rankingsStatePath: path.resolve(process.env.RANKINGS_STATE_PATH || defaultStatePath), }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; const value = argv[index + 1]; if (arg === "--host" && value) { options.host = value; index += 1; continue; } if (arg === "--port" && value) { options.port = Number.parseInt(value, 10); index += 1; continue; } if (arg === "--rankings-state-path" && value) { options.rankingsStatePath = path.resolve(value); index += 1; } } if (!Number.isInteger(options.port) || options.port <= 0 || options.port > 65535) { throw new Error("Port must be an integer between 1 and 65535"); } return options; } function getContentType(filePath) { return MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream"; } function hasHiddenSegment(pathname) { return pathname.split("/").some((segment) => segment.startsWith(".") && segment.length > 1); } function resolveRequestPath(pathname) { if (hasHiddenSegment(pathname)) { return null; } const requestedPath = pathname === "/" ? "/index.html" : pathname; const absolutePath = path.resolve(repoRoot, `.${requestedPath}`); const withinRepo = absolutePath === repoRoot || absolutePath.startsWith(`${repoRoot}${path.sep}`); if (!withinRepo) { return null; } if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isDirectory()) { return path.join(absolutePath, "index.html"); } if ( path.extname(absolutePath) === "" && !fs.existsSync(absolutePath) && fs.existsSync(`${absolutePath}.html`) && fs.statSync(`${absolutePath}.html`).isFile() ) { return `${absolutePath}.html`; } return absolutePath; } function sendJson(response, statusCode, payload) { response.writeHead(statusCode, { "Cache-Control": "no-store", "Content-Type": "application/json; charset=utf-8", }); response.end(`${JSON.stringify(payload, null, 2)}\n`); } function sendText(response, statusCode, body) { response.writeHead(statusCode, { "Cache-Control": "no-store", "Content-Type": "text/plain; charset=utf-8", }); response.end(body); } function sendRedirect(response, location) { response.writeHead(301, { Location: location, "Cache-Control": "no-store", }); response.end(); } function parseJsonBody(request) { return new Promise((resolve, reject) => { const chunks = []; let totalBytes = 0; request.on("data", (chunk) => { totalBytes += chunk.length; if (totalBytes > MAX_REQUEST_BODY_BYTES) { reject(new Error("Request body is too large")); request.destroy(); return; } chunks.push(chunk); }); request.on("end", () => { if (chunks.length === 0) { resolve({}); return; } try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch (error) { reject(new Error("Request body must be valid JSON")); } }); request.on("error", reject); }); } function sendFile(response, filePath) { const stream = fs.createReadStream(filePath); response.writeHead(200, { "Content-Type": getContentType(filePath), "Cache-Control": "no-cache", }); stream.pipe(response); stream.on("error", () => { sendText(response, 500, "Failed to read file"); }); } async function handleApiRequest(request, response, pathname, options) { if (pathname === "/api/rankings" && request.method === "GET") { sendJson(response, 200, loadRankingsState({ statePath: options.rankingsStatePath })); return true; } if (pathname === "/api/rankings/vote" && request.method === "POST") { const body = await parseJsonBody(request); sendJson(response, 200, recordVote(body, { statePath: options.rankingsStatePath })); return true; } if (pathname === "/api/rankings/reset" && request.method === "POST") { sendJson(response, 200, resetRankingsState({ statePath: options.rankingsStatePath })); return true; } if (pathname === "/api/rankings/undo" && request.method === "POST") { sendJson(response, 200, undoLastRankingsVote({ statePath: options.rankingsStatePath })); return true; } if ( pathname === "/api/rankings" || pathname === "/api/rankings/vote" || pathname === "/api/rankings/reset" || pathname === "/api/rankings/undo" ) { sendText(response, 405, "Method not allowed"); return true; } if (pathname.startsWith("/api/")) { sendText(response, 404, "Not found"); return true; } return false; } function createServer(options) { return http.createServer(async (request, response) => { let pathname; let search; try { const requestUrl = new URL(request.url || "/", "http://localhost"); pathname = decodeURIComponent(requestUrl.pathname); search = requestUrl.search; } catch (error) { sendText(response, 400, "Bad request"); return; } if (request.method === "GET" || request.method === "HEAD") { if (pathname === "/index.html") { sendRedirect(response, `/${search}`); return; } if (pathname.endsWith(".html") && pathname !== "/index.html") { sendRedirect(response, `${pathname.slice(0, -5)}${search}`); return; } } try { if (await handleApiRequest(request, response, pathname, options)) { return; } } catch (error) { const statusCode = error.message === "Request body must be valid JSON" || error.message === "Request body is too large" || error.message.startsWith("Vote payload") || error.message === "winnerId and loserId must be different" ? 400 : 500; if (statusCode >= 500) { console.error(error); } sendJson(response, statusCode, { error: error.message }); return; } const filePath = resolveRequestPath(pathname); if (!filePath) { sendText(response, 403, "Forbidden"); return; } if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { sendText(response, 404, "Not found"); return; } sendFile(response, filePath); }); } function main() { const options = parseArgs(process.argv.slice(2)); const server = createServer(options); server.listen(options.port, options.host, () => { console.log(`Serving ${repoRoot} at http://${options.host}:${options.port}`); console.log(`Rankings state path: ${options.rankingsStatePath}`); }); } if (require.main === module) { try { main(); } catch (error) { console.error(error.message); process.exitCode = 1; } }