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