← 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);