[object Object]

← back to Rentv

rentv-v1: harden newsletter capture per contrarian — idempotent dedup (hot email Set), MAX_SUBS 50k ceiling, rightmost-XFF ip; honest CRE Talk grid framing (broader REview catalog, not implied interviews-only)

255f3538d9525d9e0064b94fb63dcc37e1f373e2 · 2026-07-21 13:18:14 -0700 · Steve Abrams

Files touched

Diff

commit 255f3538d9525d9e0064b94fb63dcc37e1f373e2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 21 13:18:14 2026 -0700

    rentv-v1: harden newsletter capture per contrarian — idempotent dedup (hot email Set), MAX_SUBS 50k ceiling, rightmost-XFF ip; honest CRE Talk grid framing (broader REview catalog, not implied interviews-only)
---
 build.mjs            |  2 +-
 public/cre-talk.html |  2 +-
 server.js            | 17 +++++++++++++++--
 3 files changed, 17 insertions(+), 4 deletions(-)

diff --git a/build.mjs b/build.mjs
index 6cbb89d5..e3cee585 100644
--- a/build.mjs
+++ b/build.mjs
@@ -298,7 +298,7 @@ const creTalk = page('CRE Talk', 'CRE Talk — RENTV\'s interview series with th
   </div></section>
   <section class="blk"><div class="wrap">
     <div class="eyebrow rv">Watch</div><h2 class="big rv">From The REview.</h2>
-    <p class="lead2 rv">The latest from RENTV's live video catalog. New CRE Talk conversations post here as they're recorded.</p>
+    <p class="lead2 rv">CRE Talk episodes live on The REview, alongside RENTV's full video catalog — market panels, property features and deal breakdowns. Here's what's fresh right now.</p>
     <div class="cols3" id="vidgrid" style="margin-top:26px"><p style="color:var(--sub)">Loading the latest videos…</p></div>
     <a class="cta rv" href="/review" style="margin-top:26px">See the full REview catalog →</a>
   </div></section>
diff --git a/public/cre-talk.html b/public/cre-talk.html
index 8c933472..00fbfe18 100644
--- a/public/cre-talk.html
+++ b/public/cre-talk.html
@@ -100,7 +100,7 @@
   </div></section>
   <section class="blk"><div class="wrap">
     <div class="eyebrow rv">Watch</div><h2 class="big rv">From The REview.</h2>
-    <p class="lead2 rv">The latest from RENTV's live video catalog. New CRE Talk conversations post here as they're recorded.</p>
+    <p class="lead2 rv">CRE Talk episodes live on The REview, alongside RENTV's full video catalog — market panels, property features and deal breakdowns. Here's what's fresh right now.</p>
     <div class="cols3" id="vidgrid" style="margin-top:26px"><p style="color:var(--sub)">Loading the latest videos…</p></div>
     <a class="cta rv" href="/review" style="margin-top:26px">See the full REview catalog →</a>
   </div></section>
diff --git a/server.js b/server.js
index c95c3814..b6ff72db 100644
--- a/server.js
+++ b/server.js
@@ -50,19 +50,32 @@ app.get('/api/markets', (_q, r) => {
 
 // ── Newsletter capture: append-only local JSONL (NO external send-to-list). ──
 const SUBS = path.join(DATA, 'subscribers.jsonl');
+const MAX_SUBS = 50000; // hard ceiling — guards against disk-fill / append abuse
+// Hot set of already-captured emails, loaded once at startup, for idempotent capture.
+const subSet = new Set();
+try {
+  fs.readFileSync(SUBS, 'utf8').split('\n').filter(Boolean).forEach(l => {
+    try { const e = JSON.parse(l).email; if (e) subSet.add(e); } catch { /* skip */ }
+  });
+} catch { /* no file yet */ }
 app.post('/api/subscribe', (req, res) => {
   const b = req.body || {};
   const email = String(b.email || '').trim().toLowerCase();
   if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return res.status(400).json({ ok: false, error: 'valid email required' });
+  // Idempotent: already on the list → succeed silently, no duplicate row.
+  if (subSet.has(email)) return res.json({ ok: true, already: true });
+  if (subSet.size >= MAX_SUBS) return res.status(503).json({ ok: false, error: 'list temporarily closed' });
+  // x-forwarded-for: behind nginx the real client IP is the LAST segment (client-supplied values precede it).
+  const xff = String(req.headers['x-forwarded-for'] || '').split(',').map(s => s.trim()).filter(Boolean);
   const rec = {
     email,
     name: String(b.name || '').trim().slice(0, 120),
     interest: String(b.interest || '').trim().slice(0, 60),
     source: String(b.source || 'site').trim().slice(0, 40),
     at: new Date().toISOString(),
-    ip: (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').toString().split(',')[0].trim(),
+    ip: xff.length ? xff[xff.length - 1] : (req.socket.remoteAddress || ''), // best-effort, untrusted
   };
-  try { fs.appendFileSync(SUBS, JSON.stringify(rec) + '\n'); }
+  try { fs.appendFileSync(SUBS, JSON.stringify(rec) + '\n'); subSet.add(email); }
   catch (e) { return res.status(500).json({ ok: false, error: 'could not save' }); }
   res.json({ ok: true });
 });

← 59bb51bc rentv-v1: add /cre-talk interview-series page (live REview v  ·  back to Rentv  ·  rentv-v1 deploy: exclude data/subscribers.jsonl from rsync s e58b9691 →