[object Object]

← back to NationalPaperHangers

Add POST /api/booking-media — public upload for the room-capture flow

9df4b87b5fd91766b9555d63a3900adc5667ce75 · 2026-05-18 16:34:02 -0700 · SteveStudio2

Unauthenticated (the customer has no account) so it's IP-rate-limited at
40/hr and uses sha256 content-addressed storage — the client cannot
influence the path. Accepts JPG/PNG/WebP/HEIC images + MP4/MOV/WebM video
up to 40 MB. CSRF is satisfied via the X-CSRF-Token header (multipart
bodies aren't parsed when the CSRF middleware runs). robots.txt now
disallows /uploads/bookings/ so customer media isn't indexed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 9df4b87b5fd91766b9555d63a3900adc5667ce75
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Mon May 18 16:34:02 2026 -0700

    Add POST /api/booking-media — public upload for the room-capture flow
    
    Unauthenticated (the customer has no account) so it's IP-rate-limited at
    40/hr and uses sha256 content-addressed storage — the client cannot
    influence the path. Accepts JPG/PNG/WebP/HEIC images + MP4/MOV/WebM video
    up to 40 MB. CSRF is satisfied via the X-CSRF-Token header (multipart
    bodies aren't parsed when the CSRF middleware runs). robots.txt now
    disallows /uploads/bookings/ so customer media isn't indexed.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/robots.txt |  1 +
 routes/api.js     | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 59 insertions(+)

diff --git a/public/robots.txt b/public/robots.txt
index 58a4334..d8b2874 100644
--- a/public/robots.txt
+++ b/public/robots.txt
@@ -5,5 +5,6 @@ Disallow: /admin/
 Disallow: /bookings/
 Disallow: /api/
 Disallow: /webhooks/
+Disallow: /uploads/bookings/
 
 Sitemap: https://www.nationalpaperhangers.com/sitemap.xml
diff --git a/routes/api.js b/routes/api.js
index 487586c..1411b6f 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -1,4 +1,9 @@
 const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const multer = require('multer');
+const rateLimit = require('express-rate-limit');
 const db = require('../lib/db');
 const slots = require('../lib/slots');
 const email = require('../lib/email');
@@ -11,6 +16,59 @@ const router = express.Router();
 // PUBLIC API
 // =======================================================================
 
+// ---- Customer booking-media uploads (room photos/video, wallpaper images) ---
+// Used by the /book room-capture flow. Unauthenticated by necessity — the
+// customer has no account — so it's IP-rate-limited and content-addressed
+// (sha256) storage means the client can never influence the path.
+const BOOKING_UPLOAD_ROOT = path.join(__dirname, '..', 'public', 'uploads', 'bookings');
+const MEDIA_EXT = {
+  'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp',
+  'image/avif': '.avif', 'image/heic': '.heic', 'image/heif': '.heif',
+  'video/mp4': '.mp4', 'video/quicktime': '.mov', 'video/webm': '.webm'
+};
+const mediaUpload = multer({
+  storage: multer.memoryStorage(),
+  limits: { fileSize: 40 * 1024 * 1024, files: 1 },
+  fileFilter: (req, file, cb) => {
+    if (!MEDIA_EXT[file.mimetype]) return cb(new Error('UNSUPPORTED_TYPE'));
+    cb(null, true);
+  }
+});
+// 40 uploads/hour/IP covers a multi-room session (photo + video + wallpaper
+// per wall) while making bulk abuse expensive.
+const mediaLimiter = rateLimit({
+  windowMs: 60 * 60 * 1000, max: 40,
+  standardHeaders: true, legacyHeaders: false,
+  message: { ok: false, error: 'Upload limit reached — try again in an hour.' }
+});
+
+// POST /api/booking-media — stores one image or short video, returns its URL.
+router.post('/booking-media', mediaLimiter, (req, res) => {
+  mediaUpload.single('file')(req, res, (err) => {
+    if (err) {
+      const msg = err.message === 'UNSUPPORTED_TYPE'
+        ? 'File must be a JPG/PNG/WebP/HEIC image or an MP4/MOV/WebM video.'
+        : (err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 40 MB).' : 'Upload failed.');
+      return res.status(400).json({ ok: false, error: msg });
+    }
+    if (!req.file) return res.status(400).json({ ok: false, error: 'No file received.' });
+    try {
+      const ext = MEDIA_EXT[req.file.mimetype];
+      const hash = crypto.createHash('sha256').update(req.file.buffer).digest('hex').slice(0, 24);
+      fs.mkdirSync(BOOKING_UPLOAD_ROOT, { recursive: true });
+      const fpath = path.join(BOOKING_UPLOAD_ROOT, hash + ext);
+      if (!fs.existsSync(fpath)) fs.writeFileSync(fpath, req.file.buffer);
+      res.json({
+        ok: true,
+        url: `/uploads/bookings/${hash}${ext}`,
+        kind: req.file.mimetype.startsWith('video/') ? 'video' : 'image'
+      });
+    } catch (e) {
+      res.status(500).json({ ok: false, error: 'Could not store file.' });
+    }
+  });
+});
+
 // GET /api/installers/:slug/slots?from=YYYY-MM-DD&to=YYYY-MM-DD&duration=60
 router.get('/installers/:slug/slots', async (req, res, next) => {
   try {

← 81398eb Migration 022: room_captures JSONB on bookings  ·  back to NationalPaperHangers  ·  Add camera capture & measure step to the /book wizard d2f12ee →