← back to Ventura Claw Leads

lib/photo-upload.js

92 lines

// Multer multipart parser + sharp post-processor for /admin/profile/photo.
// Mounted as middleware in server.js BEFORE csrfMiddleware so multipart
// fields (including _csrf) land in req.body in time for CSRF verification.
//
// Pipeline:
//   1. Multer accepts upload (2 MB cap, jpg/png/webp mime whitelist)
//   2. Sharp resizes to fit 1600×1600 (preserve aspect ratio, no upscale)
//      + converts to WebP at 85% quality
//      + strips EXIF (privacy + smaller files)
//   3. Original temp file replaced; req.file.path/filename rewritten so the
//      route handler stores the final webp path in the DB.
//
// Sharp is the native libvips binding — fast (~50ms per photo on Mac),
// memory-bounded (~50MB peak), and handles all common formats including
// HEIC if libheif is available.

const path = require('node:path');
const fs = require('node:fs');
let sharp = null;
try { sharp = require('sharp'); } catch (e) { console.warn('[photo-upload] sharp not available; uploads will be saved raw'); }

module.exports = function (multer) {
  const PHOTO_DIR = path.resolve(__dirname, '..', 'public', 'uploads', 'business-photos');
  fs.mkdirSync(PHOTO_DIR, { recursive: true });

  const photoStorage = multer.diskStorage({
    destination: (req, file, cb) => cb(null, PHOTO_DIR),
    filename: (req, file, cb) => {
      // Always end up at .webp after sharp processing; multer just gets us
      // a temp file with a unique name. .tmp extension makes the temp
      // visible in `ls` if processing fails halfway.
      const bizId = (req.session && req.session.businessUserId) || 'anon';
      cb(null, `b-${bizId}-${Date.now()}.tmp`);
    }
  });

  const photoUpload = multer({
    storage: photoStorage,
    limits: { fileSize: 5 * 1024 * 1024 }, // 5MB raw — sharp will compress down
    fileFilter: (req, file, cb) => {
      if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.mimetype)) {
        return cb(new Error('only_jpg_png_webp'));
      }
      cb(null, true);
    }
  });

  async function processWithSharp(req) {
    if (!sharp || !req.file) return;
    const tmpPath = req.file.path;
    const finalName = path.basename(tmpPath, '.tmp') + '.webp';
    const finalPath = path.join(PHOTO_DIR, finalName);
    try {
      await sharp(tmpPath)
        .rotate()                                    // honor EXIF orientation BEFORE strip
        .resize({ width: 1600, height: 1600, fit: 'inside', withoutEnlargement: true })
        .webp({ quality: 85, effort: 4 })
        .withMetadata({ exif: undefined })           // strip EXIF for privacy + size
        .toFile(finalPath);
      try { fs.unlinkSync(tmpPath); } catch {}
      req.file.path = finalPath;
      req.file.filename = finalName;
      req.file.mimetype = 'image/webp';
      const stat = fs.statSync(finalPath);
      req.file.size = stat.size;
    } catch (e) {
      // Sharp failed — clean tmp, surface error.
      try { fs.unlinkSync(tmpPath); } catch {}
      throw new Error('image_decode_failed');
    }
  }

  return function multerWrap(req, res, next) {
    // GET (no body) and POST /delete (urlencoded, not multipart) skip multer.
    if (req.method !== 'POST' || req.path === '/delete') return next();
    photoUpload.single('photo')(req, res, async (err) => {
      if (err) {
        req._photoUploadError = err.code === 'LIMIT_FILE_SIZE' ? 'too_big' : (err.message || 'upload_failed');
        return next();
      }
      if (!req.file) return next();
      try {
        await processWithSharp(req);
        next();
      } catch (e) {
        req._photoUploadError = e.message || 'process_failed';
        next();
      }
    });
  };
};