← back to Paul Conrad Cartoons Rebrand
server.js
92 lines
// Inkwell — Editorial Cartoon Archive. Private local reference tool (no auth, not deployed).
// Serves public/, the research records (data/cartoons.json) and the imported P24 art (data/p24.json).
//
// TK-12230 naming rule: the served UI and every API response must never contain the name of the
// real cartoonist the research records document. data/cartoons.json is kept verbatim as the
// research source of truth; neutralizeRecords() scrubs it on the way OUT (text -> "the artist",
// URLs/paths that embed the name are withheld), and 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 neutralize(value, key = '') {
if (typeof value === 'string') {
if (!BANNED.test(value)) return value;
// A URL or file path that embeds the name cannot be reworded without breaking it: withhold it.
if (/(url|image|href|src)$/i.test(key) || /^(https?:|\/|images\/|research\/)/i.test(value)) return null;
return neutralText(value);
}
if (Array.isArray(value)) return value.map(v => neutralize(v, key));
if (value && typeof value === 'object') {
const out = {};
for (const [k, v] of Object.entries(value)) out[k] = neutralize(v, k);
return out;
}
return value;
}
function neutralizeRecords(raw) {
const data = neutralize(raw);
if (data.bio) data.bio.name = 'The artist';
// Records whose image path was withheld still need the UI's "image not displayed" state.
for (const [i, r] of (data.records || []).entries()) {
const src = raw.records[i];
if ((src.image || src.image_url) && !r.image && !r.image_url) r.image = 'withheld';
}
return data;
}
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')));
app.get('/api/cartoons', (req, res) => {
try {
sendClean(res, neutralizeRecords(readJson('data/cartoons.json')));
} catch (err) {
res.status(500).json({ error: 'could not read research records', 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) });
}
});
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}`);
});