← back to Crazy News Channel

daily-cartoons/review-server.mjs

283 lines

#!/usr/bin/env node
// P24 daily-cartoon review server (TK-12243). Zero dependencies (node:http only).
//
//   node daily-cartoons/review-server.mjs            # http://127.0.0.1:9947/  (admin / DW2024!)
//   PORT=9960 node daily-cartoons/review-server.mjs
//   P24_SITE_DIR=/tmp/p24-copy node daily-cartoons/review-server.mjs   # act on a COPY (tests)
//
// LOCAL ONLY: binds 127.0.0.1, Basic Auth. Renders the review page live from the queue via
// build_review.py --live --stdout (same template as the static review.html), serves
// assets/style.css + queue media, and exposes:
//   GET  /api/stories                       → every article from extract_stories.mjs (the picker's list)
//   POST /api/approve {items:[{date,slug,story_id?}]} → per item: if story_id is given, validate it
//        against extract_stories.mjs, persist story_id/story_title/story_url/story_source into
//        meta.json (like generate.py does), then run approve.py --story-id (execFile, no shell)
//   POST /api/delete  {items:[...]}         → MOVES queue/<date>/<slug> → queue/_trash/<date>/<slug> (never rm)
//   POST /api/restore {items:[...]}         → moves it back
// Every action is appended to daily-cartoons/logs/review-actions.jsonl. Nothing deploys.
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 HERE = path.dirname(fileURLToPath(import.meta.url));
// Mirror common.py: with P24_SITE_DIR set, the queue/review/logs live inside THAT copy.
const SITE_OVERRIDE = process.env.P24_SITE_DIR ? path.resolve(process.env.P24_SITE_DIR) : null;
const SITE = SITE_OVERRIDE || path.resolve(HERE, '..');
const DC = SITE_OVERRIDE ? path.join(SITE_OVERRIDE, 'daily-cartoons') : HERE;
const QUEUE = path.resolve(process.env.P24_CARTOON_QUEUE_DIR || path.join(DC, 'queue'));
const TRASH = path.join(QUEUE, '_trash');
const LOG = path.join(DC, 'logs', 'review-actions.jsonl');
const PY = process.env.PYTHON || 'python3';
const HOST = '127.0.0.1';
const DEFAULT_PORT = 9947;
const USER = process.env.P24_REVIEW_USER || 'admin';
const PASS = process.env.P24_REVIEW_PASS || 'DW2024!';
const MAX_ITEMS = 100;
const MAX_BODY = 64 * 1024;

const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const SLUG_RE = /^[a-z0-9-]{1,120}$/;
const STORY_RE = /^[A-Za-z0-9._:-]{1,200}$/;

class HttpError extends Error { constructor(code, msg) { super(msg); this.code = code; } }

// Strict validation + a resolved-path containment check (belt and braces against traversal).
function itemPath(root, it) {
  if (!it || typeof it !== 'object') throw new HttpError(400, 'item must be an object {date,slug}');
  const { date, slug } = it;
  if (typeof date !== 'string' || !DATE_RE.test(date)) throw new HttpError(400, `invalid date: ${JSON.stringify(date)}`);
  if (typeof slug !== 'string' || !SLUG_RE.test(slug)) throw new HttpError(400, `invalid slug: ${JSON.stringify(slug)}`);
  const p = path.resolve(root, date, slug);
  if (!p.startsWith(root + path.sep)) throw new HttpError(400, 'path escapes queue');
  return p;
}

function parseItems(body) {
  if (!body || !Array.isArray(body.items)) throw new HttpError(400, 'body must be {items:[{date,slug}]}');
  if (body.items.length === 0) throw new HttpError(400, 'items is empty');
  if (body.items.length > MAX_ITEMS) throw new HttpError(400, `max ${MAX_ITEMS} items per request`);
  const seen = new Set(), out = [];
  for (const it of body.items) {
    itemPath(QUEUE, it); // throws 400 on anything invalid — whole request is rejected
    const k = `${it.date}/${it.slug}`;
    if (it.story_id !== undefined && it.story_id !== null && typeof it.story_id !== 'string')
      throw new HttpError(400, `story_id must be a string (${k})`);
    if (!seen.has(k)) { seen.add(k); out.push({ date: it.date, slug: it.slug, ...(it.story_id ? { story_id: it.story_id } : {}) }); }
  }
  return out;
}

// Article list for the picker + story_id validation. Same extractor generate.py uses (sandboxed
// vm parse of real-news-data.js / stories-data.js), cached until either data file changes.
let storyCache = { key: null, list: null };
async function loadStories() {
  const files = ['real-news-data.js', 'stories-data.js'].map((f) => path.join(SITE, f));
  const key = (await Promise.all(files.map((f) => fsp.stat(f).then((st) => `${st.mtimeMs}:${st.size}`, () => '-')))).join('|');
  if (storyCache.key === key && storyCache.list) return storyCache.list;
  const r = await run(process.execPath, [path.join(HERE, 'extract_stories.mjs')]);
  if (r.code !== 0) throw new HttpError(500, 'extract_stories.mjs failed: ' + (r.stderr.trim().split('\n').pop() || `exit ${r.code}`));
  let list;
  try { list = JSON.parse(r.stdout); } catch { throw new HttpError(500, 'extract_stories.mjs returned invalid JSON'); }
  if (!Array.isArray(list)) throw new HttpError(500, 'extract_stories.mjs did not return an array');
  storyCache = { key, list };
  return list;
}
// Mirrors generate.py story_url(): real news links out, P24 stories link to the on-site article.
const storyUrl = (a) => (a.source === 'real-news' ? a.sourceUrl || null : `index.html#/article/${a.id}`);

const exists = (p) => fsp.access(p).then(() => true, () => false);
const readMeta = async (dir) => JSON.parse(await fsp.readFile(path.join(dir, 'meta.json'), 'utf8'));

function run(file, args) {
  return new Promise((resolve) => {
    execFile(file, args, { cwd: SITE, env: process.env, timeout: 120_000, maxBuffer: 4 << 20 },
      (err, stdout, stderr) => resolve({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, stdout, stderr, err }));
  });
}

// Actions run SEQUENTIALLY (approve.py rewrites cartoons/manifest.js — no concurrent writers).
let chain = Promise.resolve();
const serial = (fn) => { const p = chain.then(fn, fn); chain = p.catch(() => {}); return p; };

async function approveOne(it) {
  const dir = itemPath(QUEUE, it);
  if (!(await exists(path.join(dir, 'meta.json')))) return { ...it, ok: false, error: 'not in queue' };
  const before = await readMeta(dir);
  if (before.status === 'approved') return { ...it, ok: false, already: true, error: 'already approved' };
  let storyId = before.story_id || null;
  if (it.story_id) {
    if (!STORY_RE.test(it.story_id)) return { ...it, ok: false, error: `invalid story_id: ${JSON.stringify(it.story_id)}` };
    const art = (await loadStories()).find((a) => a.id === it.story_id);
    if (!art) return { ...it, ok: false, error: `unknown story_id: ${it.story_id} (not in real-news-data.js / stories-data.js)` };
    // Persist the link BEFORE approve.py runs, so approve.py renders the Source link from meta.json.
    const m = { ...before, story_id: art.id, story_title: art.headline, story_url: storyUrl(art), story_source: art.source,
      story_linked_by: 'review-server', story_linked_at: new Date().toISOString(),
      ...(before.story_id && before.story_id !== art.id ? { story_id_previous: before.story_id } : {}) };
    const mp = path.join(dir, 'meta.json'), tmp = mp + '.tmp';
    await fsp.writeFile(tmp, JSON.stringify(m, null, 2));
    await fsp.rename(tmp, mp);
    storyId = art.id;
  }
  if (!storyId) return { ...it, ok: false, error: 'no linked article — choose one in the article picker first' };
  const r = await run(PY, [path.join(HERE, 'approve.py'), it.slug, '--date', it.date, '--story-id', storyId]);
  const output = (r.stdout + r.stderr).trim().slice(-2000);
  if (r.code !== 0) return { ...it, ok: false, error: (r.stderr.trim().split('\n').pop() || `approve.py exit ${r.code}`), output };
  const m = await readMeta(dir);
  if (m.status !== 'approved') { // approve.py sets it; guarantee it even if a future version doesn't
    m.status = 'approved'; m.approved_at = m.approved_at || new Date().toISOString();
    await fsp.writeFile(path.join(dir, 'meta.json'), JSON.stringify(m, null, 2));
  }
  return { ...it, ok: true, site_file: m.site_file || null, story_id: m.story_id || storyId,
    story_title: m.story_title || null, story_url: m.story_url || null, story_source: m.story_source || null, output };
}

async function moveDir(src, dst) {
  await fsp.mkdir(path.dirname(dst), { recursive: true });
  await fsp.rename(src, dst); // same filesystem (both under queue/) → atomic move, never a copy+rm
}

async function deleteOne(it) {
  const src = itemPath(QUEUE, it), dst = itemPath(TRASH, it);
  if (!(await exists(src))) return { ...it, ok: false, error: 'not in queue' };
  let displaced = null;
  if (await exists(dst)) { // an older trashed copy with the same slug: keep it, just rename it aside
    displaced = `${it.slug}--trashed-${Date.now()}`;
    await fsp.rename(dst, path.join(path.dirname(dst), displaced));
  }
  await moveDir(src, dst);
  return { ...it, ok: true, trashed_to: path.relative(DC, dst), ...(displaced ? { displaced } : {}) };
}

async function restoreOne(it) {
  const src = itemPath(TRASH, it), dst = itemPath(QUEUE, it);
  if (!(await exists(src))) return { ...it, ok: false, error: 'not in trash' };
  if (await exists(dst)) return { ...it, ok: false, error: 'a queue item with that slug already exists' };
  await moveDir(src, dst);
  return { ...it, ok: true, restored_to: path.relative(DC, dst) };
}

const ACTIONS = { approve: approveOne, delete: deleteOne, restore: restoreOne };

async function logAction(action, items, results) {
  await fsp.mkdir(path.dirname(LOG), { recursive: true });
  const slim = results.map(({ output, ...r }) => ({ ...r, ...(output ? { output: output.slice(-500) } : {}) }));
  await fsp.appendFile(LOG, JSON.stringify({ ts: new Date().toISOString(), action, items, results: slim }) + '\n');
}

function send(res, code, body, type = 'application/json; charset=utf-8', extra = {}) {
  const buf = Buffer.isBuffer(body) ? body : Buffer.from(typeof body === 'string' ? body : JSON.stringify(body));
  res.writeHead(code, { 'Content-Type': type, 'Content-Length': buf.length, 'Cache-Control': 'no-store',
    'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', ...extra });
  res.end(buf);
}

function authed(req) {
  const h = req.headers.authorization || '';
  if (!h.startsWith('Basic ')) return false;
  const [u, ...rest] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
  return u === USER && rest.join(':') === PASS;
}

function readBody(req) {
  return new Promise((resolve, reject) => {
    let n = 0; const chunks = [];
    req.on('data', (c) => { n += c.length; if (n > MAX_BODY) { reject(new HttpError(413, 'body too large')); req.destroy(); } else chunks.push(c); });
    req.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null')); } catch { reject(new HttpError(400, 'invalid JSON')); } });
    req.on('error', reject);
  });
}

const MIME = { '.jpg': 'image/jpeg', '.mp4': 'video/mp4', '.css': 'text/css; charset=utf-8' };

async function sendFile(req, res, file) {
  let st;
  try { st = await fsp.stat(file); } catch { return send(res, 404, { error: 'not found' }); }
  if (!st.isFile()) return send(res, 404, { error: 'not found' });
  const type = MIME[path.extname(file)] || 'application/octet-stream';
  const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
  if (range && (range[1] || range[2])) { // enough Range support for <video> seeking
    let start = range[1] ? +range[1] : st.size - +range[2], end = range[1] && range[2] ? +range[2] : st.size - 1;
    if (start < 0 || start >= st.size || end < start) return send(res, 416, '', 'text/plain', { 'Content-Range': `bytes */${st.size}` });
    end = Math.min(end, st.size - 1);
    res.writeHead(206, { 'Content-Type': type, 'Content-Length': end - start + 1, 'Accept-Ranges': 'bytes',
      'Content-Range': `bytes ${start}-${end}/${st.size}`, 'Cache-Control': 'no-store' });
    return fs.createReadStream(file, { start, end }).pipe(res);
  }
  res.writeHead(200, { 'Content-Type': type, 'Content-Length': st.size, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' });
  fs.createReadStream(file).pipe(res);
}

async function renderLive() {
  const r = await run(PY, [path.join(HERE, 'build_review.py'), '--live', '--stdout']);
  if (r.code !== 0) throw new HttpError(500, 'render failed: ' + r.stderr.trim().split('\n').pop());
  return r.stdout;
}

const MEDIA_RE = /^\/daily-cartoons\/queue\/(\d{4}-\d{2}-\d{2})\/([a-z0-9-]{1,120})\/(poster\.jpg|clip\.mp4)$/;

async function handle(req, res) {
  if (!authed(req)) return send(res, 401, { error: 'auth required' }, undefined, { 'WWW-Authenticate': 'Basic realm="p24-review", charset="UTF-8"' });
  let url;
  try { url = new URL(req.url, `http://${HOST}`); } catch { return send(res, 400, { error: 'bad url' }); }
  const p = url.pathname;

  if (req.method === 'POST' && p.startsWith('/api/')) {
    const action = p.slice(5);
    if (!ACTIONS[action]) return send(res, 404, { error: 'unknown action' });
    // CSRF: require a JSON content-type (cross-site forms can't send it without a CORS preflight,
    // which we never answer) and, when the browser sends Origin, that it is this server.
    if (!/^application\/json\b/i.test(req.headers['content-type'] || '')) return send(res, 415, { error: 'Content-Type must be application/json' });
    const origin = req.headers.origin;
    if (origin && origin !== `http://${req.headers.host}`) return send(res, 403, { error: 'cross-origin request refused' });
    const items = parseItems(await readBody(req));
    const results = await serial(async () => {
      const out = [];
      for (const it of items) {
        try { out.push(await ACTIONS[action](it)); } catch (e) { out.push({ ...it, ok: false, error: e.message }); }
      }
      await logAction(action, items, out);
      return out;
    });
    return send(res, 200, { ok: results.every((r) => r.ok), action, results });
  }

  if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, { error: 'method not allowed' });
  if (p === '/api/stories') {
    const list = await loadStories();
    return send(res, 200, { count: list.length, stories: list.map(({ id, source, headline, tags, sourceName, sourceUrl, publishedAt }) =>
      ({ id, source, headline, tags, sourceName, sourceUrl, publishedAt })) });
  }
  if (p === '/' || p === '/daily-cartoons' || p === '/daily-cartoons/') return send(res, 302, '', 'text/plain', { Location: '/daily-cartoons/review.html' });
  if (p === '/daily-cartoons/review.html') return send(res, 200, await renderLive(), 'text/html; charset=utf-8');
  if (p === '/assets/style.css') return sendFile(req, res, path.join(SITE, 'assets', 'style.css'));
  if (p === '/favicon.ico') return send(res, 204, '');
  if (p === '/healthz') return send(res, 200, { ok: true, queue: QUEUE, site: SITE });
  const m = MEDIA_RE.exec(p);
  if (m) return sendFile(req, res, path.join(itemPath(QUEUE, { date: m[1], slug: m[2] }), m[3]));
  return send(res, 404, { error: 'not found' });
}

const server = http.createServer((req, res) => {
  handle(req, res).catch((e) => {
    const code = e instanceof HttpError ? e.code : 500;
    if (code === 500) console.error('[review-server]', e);
    if (!res.headersSent) send(res, code, { error: e.message });
    else res.destroy();
  });
});

// PORT env wins (and fails loudly if taken). Otherwise start at 9947 and walk up to the next free port.
const fixed = process.env.PORT ? +process.env.PORT : null;
let port = fixed ?? DEFAULT_PORT;
server.on('error', (e) => {
  if (e.code === 'EADDRINUSE' && fixed === null && port < DEFAULT_PORT + 20) { port++; server.listen(port, HOST); return; }
  console.error(`[review-server] cannot listen on ${HOST}:${port}: ${e.message}`); process.exit(1);
});
server.on('listening', () => {
  console.log(`[review-server] http://${HOST}:${server.address().port}/  (user ${USER}) · queue ${QUEUE}${SITE_OVERRIDE ? ' · SITE OVERRIDE ' + SITE : ''}`);
});
server.listen(port, HOST);