← 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
M public/robots.txtM routes/api.js
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 →