← back to Interiordesignershowroom
robustness: bigint-safe id guard across all client-value->id paths (/go, product_ids, wall_paint_id, admin+suppress routes) — no more 500 on malformed/overflow ids
39571bf31447f3bd59213c8905f21fed3068c59e · 2026-08-03 10:38:36 -0700 · Steve Abrams
Files touched
A lib/ids.jsM lib/rooms.jsM routes/admin.jsM server.js
Diff
commit 39571bf31447f3bd59213c8905f21fed3068c59e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 3 10:38:36 2026 -0700
robustness: bigint-safe id guard across all client-value->id paths (/go, product_ids, wall_paint_id, admin+suppress routes) — no more 500 on malformed/overflow ids
---
lib/ids.js | 23 +++++++++++++++++++++++
lib/rooms.js | 3 ++-
routes/admin.js | 15 ++++++++++-----
server.js | 12 +++++-------
4 files changed, 40 insertions(+), 13 deletions(-)
diff --git a/lib/ids.js b/lib/ids.js
new file mode 100644
index 0000000..132ece2
--- /dev/null
+++ b/lib/ids.js
@@ -0,0 +1,23 @@
+// The ONE guard for every path where a client-supplied value reaches a bigint id
+// column. A bad value that slips through throws "invalid input syntax for type
+// bigint" (non-numeric) or "bigint out of range" (overflow) → a 500 on, among
+// others, the affiliate REVENUE redirect. This closes all of it in one place.
+//
+// Rejects: non-digits (abc), zero/negatives, leading-zero, scientific/hex notation
+// (1e3, 0x10 — Number() would accept these and silently query the wrong id), and
+// anything above MAX_SAFE_INTEGER (which overflows the bigint column). A digit-only
+// regex FIRST is what makes it robust — Number.isInteger(1e20) is true, so a numeric
+// check alone lets overflow through.
+function intOrNull(v) {
+ if (!/^[1-9][0-9]*$/.test(String(v == null ? '' : v).trim())) return null;
+ const n = Number(v);
+ return Number.isSafeInteger(n) && n >= 1 ? n : null;
+}
+
+// Sanitize an array of client-supplied ids to valid positive ints (bad entries
+// dropped, not throwing). Used for product_ids on the room-builder endpoints.
+function intIds(arr) {
+ return Array.isArray(arr) ? arr.map(intOrNull).filter((n) => n != null) : [];
+}
+
+module.exports = { intOrNull, intIds };
diff --git a/lib/rooms.js b/lib/rooms.js
index f45a4b5..e8b5064 100644
--- a/lib/rooms.js
+++ b/lib/rooms.js
@@ -2,6 +2,7 @@
// palette, and room create/fetch/list.
const db = require('./db');
const COLS = require('./cols');
+const { intIds } = require('./ids');
const slugify = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'room';
@@ -47,7 +48,7 @@ async function createRoom(d = {}) {
const cb = ['curator', 'auto', 'visitor'].includes(d.created_by) ? d.created_by : 'visitor';
const isCurator = cb === 'curator';
const title = (d.title || `${d.style || ''} ${d.room_type || 'Room'}`).trim().replace(/\b\w/g, c => c.toUpperCase());
- const ids = Array.isArray(d.product_ids) ? d.product_ids.map(Number).filter(Boolean) : [];
+ const ids = intIds(d.product_ids);
// hotspots: [{id,box:{x,y,w,h},...}] — stored as jsonb so the saved room stays shoppable
const hotspots = Array.isArray(d.hotspots) ? d.hotspots : [];
const { rows } = await db.query(
diff --git a/routes/admin.js b/routes/admin.js
index 04f5fd4..bf2f403 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -273,8 +273,9 @@ router.get('/admin', async (req, res, next) => {
} catch (e) { next(e); }
});
-// Reject a non-numeric :id before it hits the bigint column (would 500 otherwise).
-function intId(v) { const n = Number(v); return Number.isInteger(n) && n >= 1 ? n : null; }
+// Reject a bad :id before it hits the bigint column (would 500 otherwise). Shared
+// robust guard (also closes overflow, scientific/hex notation).
+const { intOrNull: intId } = require('../lib/ids');
router.post('/admin/products/:id/featured', async (req, res, next) => {
try {
@@ -614,7 +615,9 @@ router.post('/admin/suppress/add', async (req, res, next) => {
// Re-apply ONE rule (suppress its still-visible matches).
router.post('/admin/suppress/:id/apply', async (req, res, next) => {
try {
- const { rows } = await db.query(`SELECT kind, value FROM suppress_rules WHERE id=$1`, [req.params.id]);
+ const id = intId(req.params.id);
+ if (!id) return res.redirect('/admin/suppress');
+ const { rows } = await db.query(`SELECT kind, value FROM suppress_rules WHERE id=$1`, [id]);
if (!rows.length) return res.redirect('/admin/suppress');
const n = await applyRule(rows[0].kind, rows[0].value);
res.redirect('/admin/suppress?done=' + encodeURIComponent(`Suppressed ${n} more match${n === 1 ? '' : 'es'} for “${rows[0].value}”.`));
@@ -636,7 +639,9 @@ router.post('/admin/suppress/vacuum', async (_req, res, next) => {
// (a rule is the audit record; removing it alone does NOT auto-show its products).
router.post('/admin/suppress/:id/remove', async (req, res, next) => {
try {
- const { rows } = await db.query(`SELECT kind, value FROM suppress_rules WHERE id=$1`, [req.params.id]);
+ const id = intId(req.params.id);
+ if (!id) return res.redirect('/admin/suppress');
+ const { rows } = await db.query(`SELECT kind, value FROM suppress_rules WHERE id=$1`, [id]);
if (!rows.length) return res.redirect('/admin/suppress');
const { kind, value } = rows[0];
let unhid = 0;
@@ -649,7 +654,7 @@ router.post('/admin/suppress/:id/remove', async (req, res, next) => {
unhid = r.rowCount;
}
}
- await db.query(`DELETE FROM suppress_rules WHERE id=$1`, [req.params.id]);
+ await db.query(`DELETE FROM suppress_rules WHERE id=$1`, [id]);
const msg = req.body.unhide === '1' ? `Removed rule “${value}” and un-hid ${unhid} product${unhid === 1 ? '' : 's'}.` : `Removed rule “${value}”.`;
res.redirect('/admin/suppress?done=' + encodeURIComponent(msg));
} catch (e) { next(e); }
diff --git a/server.js b/server.js
index c0fa48e..b22768c 100644
--- a/server.js
+++ b/server.js
@@ -20,9 +20,7 @@ app.use('/img', express.static(path.join(__dirname, 'public/img')));
const { ROOMS } = require('./lib/nav'); // shared with render.js (nav) — one source of truth
-// Positive-integer id or null. Guards every place a client-supplied value reaches a
-// bigint column, so a non-numeric input 404s / no-ops instead of throwing a 500.
-const intOrNull = (v) => { const n = Number(v); return Number.isInteger(n) && n >= 1 ? n : null; };
+const { intOrNull, intIds } = require('./lib/ids'); // bigint-safe id guards (shared)
// --- tiny markdown-lite for guide bodies (headings, bold, paragraphs) ------
function md(src = '') {
@@ -246,8 +244,8 @@ app.get('/go/:id', async (req, res, next) => {
// Validate the id BEFORE it reaches the bigint column. A non-numeric id like
// /go/abc would otherwise throw "invalid input syntax for type bigint" and 500
// the revenue path (bots + mangled links hit this). Non-int → 404, cleanly.
- const id = Number(req.params.id);
- if (!Number.isInteger(id) || id < 1) return next();
+ const id = intOrNull(req.params.id);
+ if (!id) return next();
const { rows } = await db.query(`SELECT id, network, advertiser, affiliate_url FROM products WHERE id=$1`, [id]);
if (!rows.length) return next();
const p = rows[0];
@@ -369,7 +367,7 @@ app.post('/api/rooms', async (req, res, next) => {
const cleanSpots = Array.isArray(b.hotspots)
? b.hotspots.filter((h) => h && Number(h.id) && h.box && ['x', 'y', 'w', 'h'].every((k) => typeof h.box[k] === 'number')).slice(0, 40)
: [];
- let ids = Array.isArray(b.product_ids) ? b.product_ids.map(Number).filter(Boolean).slice(0, 40) : [];
+ let ids = intIds(b.product_ids).slice(0, 40);
// "Generate from a vibe alone": back-fill matching pieces, loosening filters
// until we always land a populated room (room+style -> style -> color -> room -> any).
if (ids.length === 0) {
@@ -452,7 +450,7 @@ app.post('/api/render', async (req, res) => {
if (!renderAllowed(ip)) return res.status(429).json({ error: 'Render limit reached — try again in a bit.' });
const b = req.body || {};
// full product set (order-preserved) for hotspot mapping; scene refs use the first few
- const ids = Array.isArray(b.product_ids) ? b.product_ids.map(Number).filter(Boolean).slice(0, 12) : [];
+ const ids = intIds(b.product_ids).slice(0, 12);
let products = [];
if (ids.length) {
const r = await db.query('SELECT id,title,price,sale_price,image_url,advertiser FROM products WHERE id = ANY($1)', [ids]);
← aae117f harden: intOrNull() guard on client-supplied ids reaching bi
·
back to Interiordesignershowroom
·
chore: v0.3.0 — keyword/id suppression feature (session clos ffa8079 →