← back to NationalPaperHangers
lib/room-captures.js
75 lines
// 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 };