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