← back to Paul Conrad Cartoons Shadowman
server.js
181 lines
// Inkwell — Editorial Cartoon Archive. Private local reference tool (no auth, not deployed).
// Serves public/, the public-domain cartoon gallery (data/pd-cartoons.json) and the imported P24 art (data/p24.json).
// data/cartoons.json (research records) is kept on disk as source material but is no longer served.
//
// TK-12230 naming rule: the served UI and every API response must never contain the name of the
// real cartoonist the (no longer served) research records document; sendClean() refuses to send
// any response that still contains the name.
const express = require('express');
const path = require('path');
const fs = require('fs');
const app = express();
const PORT = process.env.PORT || 9933;
const BANNED = /conrad/i;
function neutralText(s) {
return String(s)
.replace(/\bPaul\s+(Francis\s+)?Conrad['’]s\b/gi, "the artist's")
.replace(/\bPaul\s+(Francis\s+)?Conrad\b/gi, 'the artist')
.replace(/\bConrad\s+estate\b/gi, "the artist's estate")
.replace(/\bConrad['’]s\b/gi, "the artist's")
.replace(/\bConrad\b/gi, 'the artist')
.replace(/conrad/gi, 'artist');
}
function sendClean(res, obj) {
const body = JSON.stringify(obj);
if (BANNED.test(body)) return res.status(500).json({ error: 'response blocked: naming rule (TK-12230)' });
res.type('application/json').send(body);
}
function readJson(rel) {
return JSON.parse(fs.readFileSync(path.join(__dirname, rel), 'utf8'));
}
app.use(express.static(path.join(__dirname, 'public')));
// Public-domain cartoons from museum / library / university open-access APIs
// (built by scripts/fetch-pd-cartoons.mjs). Replaces the text-only research-records section.
app.get('/api/pd-cartoons', (req, res) => {
try {
sendClean(res, readJson('data/pd-cartoons.json'));
} catch (err) {
res.status(500).json({ error: 'could not read data/pd-cartoons.json', detail: neutralText(err.message) });
}
});
// All imported P24 photos + cartoons. Optional ?type=photo|cartoon.
app.get('/api/p24', (req, res) => {
try {
const doc = readJson('data/p24.json');
const type = req.query.type;
const items = type ? doc.items.filter(i => i.type === type) : doc.items;
sendClean(res, { ...doc, items });
} catch (err) {
res.status(500).json({ error: 'could not read data/p24.json', detail: neutralText(err.message) });
}
});
// ---- Curation (TK-12241): Shadow Man cartoons + Model Arena ideas ------------------------------
// Both collections carry a per-item status: pending | approved | deleted. Mutations are POST-only,
// LOOPBACK-ONLY (this is a no-auth local tool; a LAN client must not be able to curate), run the
// response through sendClean(), and append one line per action to data/shadowman-actions.jsonl.
// Deleting a Shadow Man piece MOVES its image to generator/out/_trash/ (never unlinks); restore
// moves it back. Approving an arena idea appends a brief to generator/idea-queue.json (the queue
// generator/compose-prompts.mjs --from-queue turns into the next render batch); restore removes it.
const DATA_DIR = path.join(__dirname, 'data');
const PUB_SM = path.join(__dirname, 'public', 'shadowman');
const TRASH = path.join(__dirname, 'generator', 'out', '_trash');
const QUEUE = path.join(__dirname, 'generator', 'idea-queue.json');
const ACTIONS = path.join(DATA_DIR, 'shadowman-actions.jsonl');
app.use(express.json({ limit: '64kb' }));
function writeJsonAtomic(file, obj) {
const tmp = file + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
fs.renameSync(tmp, file);
}
function loopbackOnly(req, res, next) {
const ip = req.socket.remoteAddress || '';
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return next();
res.status(403).json({ error: 'curation is loopback-only' });
}
function listFrom(doc, query) {
const { theme, era, model, status } = query;
return doc.items.filter(i => (!theme || i.theme === theme) && (!era || i.era === era) && (!model || i.model === model) && (!status || i.status === status));
}
function moveFile(from, to) {
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.renameSync(from, to);
}
const COLLECTIONS = {
shadowman: {
file: path.join(DATA_DIR, 'shadowman.json'),
onDelete(item) {
const src = path.join(PUB_SM, item.id + '.jpg');
if (fs.existsSync(src)) moveFile(src, path.join(TRASH, item.id + '.jpg'));
},
onRestore(item) {
const t = path.join(TRASH, item.id + '.jpg');
if (item.status === 'deleted' && fs.existsSync(t)) moveFile(t, path.join(PUB_SM, item.id + '.jpg'));
},
},
'arena-ideas': {
file: path.join(DATA_DIR, 'arena-ideas.json'),
onApprove(item) {
const q = fs.existsSync(QUEUE) ? JSON.parse(fs.readFileSync(QUEUE, 'utf8')) : { note: 'Approved Model Arena ideas waiting to be rendered. node generator/compose-prompts.mjs --from-queue turns un-batched entries into generator/out/batch-queue-<date>.json.', items: [] };
if (!q.items.some(b => b.id === item.id)) {
q.items.push({ id: item.id, theme: item.theme, era: null, title: item.title, scene: item.scene, caption: item.caption, source: 'model-arena:' + item.model, queued_at: new Date().toISOString(), batched_at: null });
writeJsonAtomic(QUEUE, q);
}
},
onDelete(item) { dequeue(item); },
onRestore(item) { dequeue(item); },
},
};
// Un-approving (restore) or deleting an approved idea pulls its brief back out of the queue,
// unless it was already handed to a render batch (then the batch is the record).
function dequeue(item) {
if (item.status !== 'approved' || !fs.existsSync(QUEUE)) return;
const q = JSON.parse(fs.readFileSync(QUEUE, 'utf8'));
const before = q.items.length;
q.items = q.items.filter(b => b.id !== item.id || b.batched_at);
if (q.items.length !== before) writeJsonAtomic(QUEUE, q);
}
for (const [name, col] of Object.entries(COLLECTIONS)) {
app.get('/api/' + name, (req, res) => {
try {
const doc = readJson(path.relative(__dirname, col.file));
sendClean(res, { ...doc, items: listFrom(doc, req.query) });
} catch (err) {
res.status(500).json({ error: `could not read ${path.basename(col.file)}`, detail: neutralText(err.message) });
}
});
for (const action of ['approve', 'delete', 'restore']) {
app.post(`/api/${name}/${action}`, loopbackOnly, (req, res) => {
try {
const ids = Array.isArray(req.body && req.body.ids) ? [...new Set(req.body.ids.map(String))] : [];
if (!ids.length) return res.status(400).json({ error: 'ids[] required' });
const doc = JSON.parse(fs.readFileSync(col.file, 'utf8'));
const byId = new Map(doc.items.map(i => [i.id, i]));
const unknown = ids.filter(id => !byId.has(id));
if (unknown.length) return res.status(404).json({ error: 'unknown ids', ids: unknown });
const now = new Date().toISOString();
const changed = [];
for (const id of ids) {
const it = byId.get(id);
const next = action === 'approve' ? 'approved' : action === 'delete' ? 'deleted' : 'pending';
if (it.status === next) continue;
if (action === 'approve' && it.status === 'deleted') continue; // restore first
if (action === 'approve' && col.onApprove) col.onApprove(it);
if (action === 'delete' && col.onDelete) col.onDelete(it);
if (action === 'restore' && col.onRestore) col.onRestore(it);
it.status = next;
it.status_at = now;
changed.push(id);
}
writeJsonAtomic(col.file, doc);
fs.appendFileSync(ACTIONS, JSON.stringify({ ts: now, collection: name, action, ids, changed }) + '\n');
sendClean(res, { ok: true, action, changed, items: doc.items });
} catch (err) {
res.status(500).json({ error: `${action} failed`, detail: neutralText(err.message) });
}
});
}
}
app.get('/health', (req, res) => res.json({ ok: true, app: 'inkwell' }));
app.listen(PORT, () => {
console.log(`Inkwell — Editorial Cartoon Archive running at http://localhost:${PORT}`);
});