← back to NationalPaperHangers
Persist room_captures on booking POST + derive sq ft from measured walls
6557a780955ba9b3c3aa8bfb179a69641f28caed · 2026-05-18 16:47:13 -0700 · SteveStudio2
New lib/room-captures.js sanitizes the customer-supplied capture JSON:
every photo/video/wallpaper URL must match the exact content-addressed
shape /api/booking-media produces (off-site URLs and path traversal are
dropped), numbers are clamped, the array is capped at 20 rooms, and a
wallpaper missing its width or repeat is dropped. The booking POST stores
the clean array and, when square_feet was left blank, backfills it from
the captured wall total. Verified: 7 sanitizer cases incl. off-site URL +
traversal + overflow; full e2e suite still 166/166.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A lib/room-captures.jsM routes/api.js
Diff
commit 6557a780955ba9b3c3aa8bfb179a69641f28caed
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Mon May 18 16:47:13 2026 -0700
Persist room_captures on booking POST + derive sq ft from measured walls
New lib/room-captures.js sanitizes the customer-supplied capture JSON:
every photo/video/wallpaper URL must match the exact content-addressed
shape /api/booking-media produces (off-site URLs and path traversal are
dropped), numbers are clamped, the array is capped at 20 rooms, and a
wallpaper missing its width or repeat is dropped. The booking POST stores
the clean array and, when square_feet was left blank, backfills it from
the captured wall total. Verified: 7 sanitizer cases incl. off-site URL +
traversal + overflow; full e2e suite still 166/166.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
lib/room-captures.js | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++
routes/api.js | 19 +++++++++++---
2 files changed, 89 insertions(+), 4 deletions(-)
diff --git a/lib/room-captures.js b/lib/room-captures.js
new file mode 100644
index 0000000..209ca78
--- /dev/null
+++ b/lib/room-captures.js
@@ -0,0 +1,74 @@
+// Sanitiser for the customer-supplied room_captures JSON posted from the
+// /book camera-capture flow (UX #6). The customer fully controls this blob,
+// so every URL is checked against the exact content-addressed shape our own
+// upload endpoint (/api/booking-media) produces — an attacker cannot make a
+// booking reference an off-site or arbitrary-path resource.
+
+const IMG_RE = /^\/uploads\/bookings\/[0-9a-f]{24}\.(jpg|png|webp|avif|heic|heif)$/;
+const VID_RE = /^\/uploads\/bookings\/[0-9a-f]{24}\.(mp4|mov|webm)$/;
+const MATCH_TYPES = new Set(['straight', 'half_drop']);
+const MAX_ROOMS = 20;
+
+function posNum(v, max) {
+ const n = parseFloat(v);
+ if (!isFinite(n) || n <= 0) return null;
+ return Math.round(Math.min(n, max) * 100) / 100;
+}
+
+// Returns a clean array; drops anything malformed. Never throws.
+function sanitize(raw) {
+ let arr;
+ try { arr = typeof raw === 'string' ? JSON.parse(raw) : raw; }
+ catch (e) { return []; }
+ if (!Array.isArray(arr)) return [];
+
+ const out = [];
+ for (const item of arr.slice(0, MAX_ROOMS)) {
+ if (!item || typeof item !== 'object') continue;
+ const photo = typeof item.photo_url === 'string' && IMG_RE.test(item.photo_url)
+ ? item.photo_url : null;
+ if (!photo) continue; // a capture with no valid photo is meaningless
+
+ const entry = {
+ room: String(item.room || 'Room').trim().slice(0, 80) || 'Room',
+ wall_width_ft: posNum(item.wall_width_ft, 200),
+ wall_height_ft: posNum(item.wall_height_ft, 200),
+ photo_url: photo,
+ video_url: typeof item.video_url === 'string' && VID_RE.test(item.video_url)
+ ? item.video_url : null,
+ wallpaper: null
+ };
+
+ const wp = item.wallpaper;
+ if (wp && typeof wp === 'object'
+ && typeof wp.image_url === 'string' && IMG_RE.test(wp.image_url)) {
+ const widthIn = posNum(wp.width_in, 240);
+ const repeatIn = posNum(wp.repeat_in, 240);
+ // Width + repeat are mandatory — without them the paper can't be shown
+ // at true scale, so an under-specified wallpaper is dropped entirely.
+ if (widthIn && repeatIn) {
+ entry.wallpaper = {
+ image_url: wp.image_url,
+ width_in: widthIn,
+ repeat_in: repeatIn,
+ match_type: MATCH_TYPES.has(wp.match_type) ? wp.match_type : 'straight'
+ };
+ }
+ }
+ out.push(entry);
+ }
+ return out;
+}
+
+// Sum of confirmed wall areas (sq ft), rounded — null when nothing measured.
+function totalSqFt(captures) {
+ let t = 0;
+ for (const c of captures) {
+ if (c.wall_width_ft > 0 && c.wall_height_ft > 0) {
+ t += c.wall_width_ft * c.wall_height_ft;
+ }
+ }
+ return t > 0 ? Math.round(t) : null;
+}
+
+module.exports = { sanitize, totalSqFt };
diff --git a/routes/api.js b/routes/api.js
index 1411b6f..7bbb14d 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -8,6 +8,7 @@ const db = require('../lib/db');
const slots = require('../lib/slots');
const email = require('../lib/email');
const bookingToken = require('../lib/booking-token');
+const roomCapturesLib = require('../lib/room-captures');
const stripe = require('../lib/stripe');
const { requireInstaller } = require('../lib/auth');
const router = express.Router();
@@ -104,6 +105,7 @@ router.post('/installers/:slug/book', async (req, res, next) => {
const client = await db.pool.connect();
let booked = null;
let installer = null;
+ let roomCaptures = [];
try {
// Read installer (no transaction yet) just to validate existence + tier.
installer = await db.one(
@@ -182,6 +184,14 @@ router.post('/installers/:slug/book', async (req, res, next) => {
: null;
const consumerAccountId = (req.session && req.session.consumerAccountId) || null;
+ // Camera-captured rooms (UX #6) — validated against our own upload-path
+ // shape. When the customer measured walls but left square_feet blank,
+ // fall back to the captured total so the installer always has a number.
+ roomCaptures = roomCapturesLib.sanitize(f.room_captures);
+ const squareFeet = f.square_feet
+ ? parseInt(f.square_feet, 10)
+ : roomCapturesLib.totalSqFt(roomCaptures);
+
const ins = await client.query(
`INSERT INTO bookings
(installer_id, customer_name, customer_email, customer_phone,
@@ -189,18 +199,18 @@ router.post('/installers/:slug/book', async (req, res, next) => {
surfaces, rooms, square_feet, roll_count_estimate, budget_band,
ceiling_height_ft, surface_state, access_constraints,
address_line1, address_line2, city, state, zip,
- customer_role, product_sourced, consumer_account_id,
+ customer_role, product_sourced, consumer_account_id, room_captures,
scheduled_start, scheduled_end, customer_notes, status, source)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,'pending','web')
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26::jsonb,$27,$28,$29,'pending','web')
RETURNING id, uuid`,
[
installer.id,
f.customer_name, f.customer_email, f.customer_phone || null,
f.project_type || 'consultation', f.market_segment || null, f.material || null, f.brand || null, brandSku,
- f.surfaces || null, f.rooms || null, f.square_feet ? parseInt(f.square_feet,10) : null, rollCountEst, f.budget_band || null,
+ f.surfaces || null, f.rooms || null, squareFeet, rollCountEst, f.budget_band || null,
ceilingHeightFt, surfaceState, accessCon,
f.address_line1 || null, f.address_line2 || null, f.city || null, (f.state || '').toUpperCase() || null, f.zip || null,
- customerRole, productSourced, consumerAccountId,
+ customerRole, productSourced, consumerAccountId, JSON.stringify(roomCaptures),
start.toISOString(), end.toISOString(), f.customer_notes || null
]
);
@@ -222,6 +232,7 @@ router.post('/installers/:slug/book', async (req, res, next) => {
...req.body, id: booked.id, uuid: booked.uuid,
scheduled_start: new Date(req.body.scheduled_start),
scheduled_end: new Date(req.body.scheduled_end),
+ room_captures: roomCaptures, // sanitized array, not the raw posted string
view_token: sig
};
← d2f12ee Add camera capture & measure step to the /book wizard
·
back to NationalPaperHangers
·
Surface room captures in the installer brief — admin page + a1dfc05 →