← back to Crazy News Channel
scripts/admin-server.mjs
236 lines
#!/usr/bin/env node
// admin-server.mjs — tiny zero-dependency local admin server for crazy-news-channel.
// Run: node scripts/admin-server.mjs then open http://127.0.0.1:8936/
//
// Serves the project's static files (index.html, *-data.js, images/, etc.)
// and exposes POST /api/refresh-real-news, which shells out to
// scripts/fetch-real-news.mjs so the admin panel's "Refresh Real News"
// button can trigger it without Steve typing the command in a terminal.
//
// This does NOT replace the project's normal `file://` "no server needed"
// default — it's an opt-in admin tool. Binds 127.0.0.1 ONLY (never 0.0.0.0),
// so it is never reachable from outside this machine.
import http from "node:http";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { execFile } from "node:child_process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..");
const FETCH_SCRIPT = path.join(__dirname, "fetch-real-news.mjs");
const REAL_NEWS_FILE = path.join(ROOT, "real-news-data.js");
const PORT = Number(process.env.PORT) || 8936;
const HOST = "127.0.0.1"; // localhost ONLY — never 0.0.0.0 / wider network
const REFRESH_TIMEOUT_MS = 30_000;
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".webp": "image/webp",
".ico": "image/x-icon",
".txt": "text/plain; charset=utf-8",
};
function contentTypeFor(filePath) {
return MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
}
// Only these top-level entries are servable — the exact set index.html loads.
// Everything else at ROOT (.git/, scripts/ source, .env, yolo/, …) is NOT
// exposed, even though this is a localhost-only tool: no VCS metadata, no
// source, no secrets file leaks to anything that can reach the port.
const SERVABLE_TOP_LEVEL = new Set([
"index.html",
"real-news-data.js",
"stories-data.js",
"cartoons",
"images",
]);
async function serveStatic(req, res) {
// decodeURIComponent throws URIError on malformed percent-encoding (e.g.
// "/%"). It MUST be caught — an unhandled rejection here crashes the whole
// process on Node's default --unhandled-rejections=throw.
let urlPath;
try {
urlPath = decodeURIComponent(req.url.split("?")[0]);
} catch {
res.writeHead(400, { "Content-Type": "text/plain" });
res.end("Bad request");
return;
}
if (urlPath === "/") urlPath = "/index.html";
const resolved = path.normalize(path.join(ROOT, urlPath));
// Path-traversal guard — the boundary is the DIRECTORY, not the string
// prefix. Without the separator, a sibling like "<ROOT>-secrets/x" would
// pass a bare startsWith(ROOT). Require exact ROOT or ROOT + separator.
if (resolved !== ROOT && !resolved.startsWith(ROOT + path.sep)) {
res.writeHead(403, { "Content-Type": "text/plain" });
res.end("Forbidden");
return;
}
// Allowlist: the first path segment under ROOT must be servable.
const rel = path.relative(ROOT, resolved);
const topSegment = rel.split(path.sep)[0];
if (!SERVABLE_TOP_LEVEL.has(topSegment)) {
res.writeHead(403, { "Content-Type": "text/plain" });
res.end("Forbidden");
return;
}
try {
const stat = await fsp.stat(resolved);
const filePath = stat.isDirectory() ? path.join(resolved, "index.html") : resolved;
const data = await fsp.readFile(filePath);
res.writeHead(200, { "Content-Type": contentTypeFor(filePath) });
res.end(data);
} catch (err) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not found");
}
}
function runFetchScript() {
return new Promise((resolve) => {
execFile(
process.execPath,
[FETCH_SCRIPT],
{ cwd: ROOT, timeout: REFRESH_TIMEOUT_MS },
(error, stdout, stderr) => {
resolve({ error, stdout: stdout || "", stderr: stderr || "" });
}
);
});
}
function parseCounts(stdout) {
const counts = {};
for (const line of stdout.split("\n")) {
const m = line.match(/^✓ (\w+): (\d+) stories/);
if (m) counts[m[1]] = Number(m[2]);
}
const totalMatch = stdout.match(/Wrote (\d+) real stories/);
return { total: totalMatch ? Number(totalMatch[1]) : null, byCategory: counts };
}
// Reject cross-origin drive-by POSTs. A 127.0.0.1 bind is NOT a CSRF boundary:
// any web page open in the user's browser can fetch() this endpoint. A simple
// POST isn't preflighted, so we validate Origin (when present) ourselves and
// only allow same-origin / null. This is the cheap close on the drive-by-
// refresh vector; the action itself is already injection-safe (execFile, fixed
// script, no user args).
function isAllowedOrigin(req) {
const origin = req.headers.origin;
if (!origin) return true; // curl / same-origin navigations send no Origin
return (
origin === `http://${HOST}:${PORT}` ||
origin === `http://localhost:${PORT}`
);
}
async function handleRefresh(req, res) {
if (!isAllowedOrigin(req)) {
console.warn(`[admin-server] refresh REJECTED — bad Origin: ${req.headers.origin}`);
res.writeHead(403, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Forbidden origin" }));
return;
}
console.log(`[admin-server] refresh requested at ${new Date().toISOString()}`);
let beforeMtime = null;
try {
beforeMtime = (await fsp.stat(REAL_NEWS_FILE)).mtimeMs;
} catch {
// file may not exist yet — that's fine
}
const { error, stdout, stderr } = await runFetchScript();
if (error) {
console.error(`[admin-server] refresh FAILED: ${error.message}`);
if (stderr) console.error(stderr);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
ok: false,
error: error.killed ? "Timed out after 30s" : error.message,
stdout,
stderr,
})
);
return;
}
let afterMtime = null;
try {
afterMtime = (await fsp.stat(REAL_NEWS_FILE)).mtimeMs;
} catch {
// ignore
}
const counts = parseCounts(stdout);
console.log(
`[admin-server] refresh OK — total=${counts.total} categories=${JSON.stringify(counts.byCategory)}`
);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
ok: true,
counts,
fileUpdated: beforeMtime !== afterMtime,
stdout,
})
);
}
const server = http.createServer(async (req, res) => {
if (req.method === "POST" && req.url === "/api/refresh-real-news") {
try {
await handleRefresh(req, res);
} catch (err) {
console.error("[admin-server] unexpected error:", err);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: String(err) }));
}
return;
}
if (req.method === "GET" || req.method === "HEAD") {
// Belt-and-suspenders: serveStatic already guards its own inputs, but
// never let a per-request error escape and take down the process.
try {
await serveStatic(req, res);
} catch (err) {
console.error("[admin-server] serveStatic error:", err);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("Internal error");
}
}
return;
}
res.writeHead(405, { "Content-Type": "text/plain" });
res.end("Method not allowed");
});
server.listen(PORT, HOST, () => {
console.log(`crazy-news-channel admin server running at http://${HOST}:${PORT}/`);
console.log(`(bound to ${HOST} only — not reachable outside this machine)`);
console.log("Press Ctrl+C to stop.");
});