260 lines
6.6 KiB
JavaScript
260 lines
6.6 KiB
JavaScript
const fs = require("fs");
|
|
const http = require("http");
|
|
const path = require("path");
|
|
|
|
const { repoRoot } = require("./lib/meals");
|
|
const {
|
|
defaultStatePath,
|
|
loadRankingsState,
|
|
recordVote,
|
|
resetRankingsState,
|
|
} = 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");
|
|
}
|
|
|
|
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 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" || pathname === "/api/rankings/vote" || pathname === "/api/rankings/reset") {
|
|
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;
|
|
|
|
try {
|
|
pathname = decodeURIComponent(new URL(request.url || "/", "http://localhost").pathname);
|
|
} catch (error) {
|
|
sendText(response, 400, "Bad request");
|
|
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;
|
|
}
|
|
}
|