[object Object]

← back to Rentv 2026

Content Studio hardening: localize blog images on publish/edit (no third-party hotlinks) + scrub all known wire names (Cody gate)

4d3751bd464af672a1e44b9bd32050a91eee3a71 · 2026-08-04 17:09:55 -0700 · steve

Files touched

Diff

commit 4d3751bd464af672a1e44b9bd32050a91eee3a71
Author: steve <steve@designerwallcoverings.com>
Date:   Tue Aug 4 17:09:55 2026 -0700

    Content Studio hardening: localize blog images on publish/edit (no third-party hotlinks) + scrub all known wire names (Cody gate)
---
 server.js | 41 ++++++++++++++++++++++++++++++++++-------
 1 file changed, 34 insertions(+), 7 deletions(-)

diff --git a/server.js b/server.js
index 8f7a2ecb..0cf92c78 100644
--- a/server.js
+++ b/server.js
@@ -239,14 +239,17 @@ app.get('/api/posts/:id', (req, res) => {
   if (!p) return res.status(404).json({ error: 'not found' });
   res.json(p);
 });
-app.post('/api/posts', adminOnly, (req, res) => {
+app.post('/api/posts', adminOnly, async (req, res) => {
   const b = req.body || {};
   if (!clean(b.title)) return res.status(400).json({ ok: false, error: 'title required' });
   const now = new Date().toISOString();
+  // Never hotlink a third-party CDN image on the live blog — download it locally (fails closed to '').
+  let image = clean(b.image, 500);
+  if (image && /^https?:\/\//i.test(image)) image = await localizeImage(image);
   const post = {
     id: 'rp' + Date.now().toString(36),
     title: clean(b.title, 200), cat: clean(b.cat, 40) || 'RENTV', author: clean(b.author, 80) || 'RENTV Staff',
-    dek: clean(b.dek, 300), image: clean(b.image, 500),
+    dek: clean(b.dek, 300), image,
     body: clean(b.body, 40000), status: b.status === 'published' ? 'published' : 'draft',
     created_at: now, updated_at: now,
   };
@@ -254,11 +257,16 @@ app.post('/api/posts', adminOnly, (req, res) => {
   try { writePosts(a); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
   res.json({ ok: true, post });
 });
-app.put('/api/posts/:id', adminOnly, (req, res) => {
+app.put('/api/posts/:id', adminOnly, async (req, res) => {
   const a = readPosts(), i = a.findIndex(x => x.id === req.params.id);
   if (i < 0) return res.status(404).json({ ok: false, error: 'not found' });
   const b = req.body || {}, f = ['title', 'cat', 'author', 'dek', 'image', 'body'];
-  f.forEach(k => { if (b[k] != null) a[i][k] = clean(b[k], k === 'body' ? 40000 : 500); });
+  for (const k of f) {
+    if (b[k] == null) continue;
+    let v = clean(b[k], k === 'body' ? 40000 : 500);
+    if (k === 'image' && v && /^https?:\/\//i.test(v)) v = await localizeImage(v); // de-hotlink on edit too
+    a[i][k] = v;
+  }
   if (b.status) a[i].status = b.status === 'published' ? 'published' : 'draft';
   a[i].updated_at = new Date().toISOString();
   try { writePosts(a); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
@@ -337,6 +345,23 @@ app.get('/api/article/:id', async (req, res) => {
 //    publishing the blog and saving social drafts are separate explicit actions in the UI.
 const CONTENT_LLM = process.env.CONTENT_LLM_MODEL || 'gemma3:12b';
 const OLLAMA_URL = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
+// Known outlet names scrubbed from generated output so no wire is ever named — the item's own
+// source PLUS every source in the archive PLUS common CRE wires the model might paraphrase in.
+// Only unambiguous multi-word/publication names (no generic single words like a company that
+// happens to share a name) to avoid mangling legitimate deal text. Cached ~10min.
+let KNOWN_SOURCES = null, KNOWN_SOURCES_AT = 0;
+function knownSources() {
+  if (KNOWN_SOURCES && Date.now() - KNOWN_SOURCES_AT < 600000) return KNOWN_SOURCES;
+  const base = ['Bisnow', 'GlobeSt', 'Globe St', 'The Real Deal', 'Commercial Observer', 'Connect CRE',
+    'REBusinessOnline', 'RE Business Online', 'Multi-Housing News', 'Commercial Property Executive',
+    'The Business Journals', 'Shopping Center Business', 'Yield PRO', 'EIN News', 'The Registry'];
+  const set = new Set(base);
+  const arch = readJSON('news-archive.json', []);
+  for (const a of (Array.isArray(arch) ? arch : arch.items || [])) if (a.source && a.source !== 'RENTV') set.add(a.source);
+  KNOWN_SOURCES = [...set].filter(s => s && s.length >= 4).sort((a, b) => b.length - a.length); // longest-first so containers strip before parts
+  KNOWN_SOURCES_AT = Date.now();
+  return KNOWN_SOURCES;
+}
 app.post('/api/content/generate', adminOnly, async (req, res) => {
   const b = req.body || {};
   let title = clean(b.title, 300), body = clean(b.body, 12000), cat = clean(b.cat, 40);
@@ -360,12 +385,14 @@ SOURCE CATEGORY: ${cat || 'CRE'}
 SOURCE BODY:
 ${body || '(no body available — work from the title)'}`;
   // Safety-net scrub: strip the source name, common attribution lead-ins, and any URLs from every output.
+  const names = [...new Set([...(source ? [source] : []), ...knownSources()])].sort((a, b) => b.length - a.length);
   const scrub = (s) => {
     let t = String(s == null ? '' : s);
-    if (source) t = t.replace(new RegExp(source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), '');
+    for (const nm of names) t = t.replace(new RegExp('\\b' + nm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'gi'), '');
     t = t.replace(/https?:\/\/\S+/g, '');
-    t = t.replace(/\b(according to|as reported by|as first reported by|per|via|reported by|reports? that|sources? (?:told|said))\b[\s,:]*/gi, '');
-    return t.replace(/\(\s*\)/g, '').replace(/\s{2,}/g, ' ').replace(/\s+([.,;:])/g, '$1').replace(/^[\s,;:.-]+/, '').trim();
+    // strip attribution lead-ins the model may paraphrase (the outlet name itself is already gone above)
+    t = t.replace(/\b(according to|as (?:first )?reported by|first reported by|as (?:the )?(?:outlet|publication|report|paper)s? (?:noted|reported)|per|via|reported by|reports? that|was covered by|covered by|sources? (?:told|said))\b[\s,:]*/gi, '');
+    return t.replace(/\(\s*\)/g, '').replace(/\s{2,}/g, ' ').replace(/\s+([.,;:])/g, '$1').replace(/^[\s,;:.\-]+/, '').trim();
   };
   const started = Date.now();
   try {

← 4ce9fdce auto-save: 2026-08-04T17:05:12 (4 files) — .deploy.conf data  ·  back to Rentv 2026  ·  Content Studio: cross-source dedup in feed (strict, Cody-har dd0317e8 →