← 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 →