← 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 };