← 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.");
});