Image Processing¶
Overview¶
The backend processes listing images through a pipeline that validates, resizes, and converts images to WebP format. Images can be stored locally or uploaded to Cloudinary.
Pipeline¶
Client Upload (multipart/form-data)
│
▼
┌─────────────────┐
│ Multer Storage │ ── Temp file saved to uploads/
└────────┬────────┘
│
▼
┌─────────────────┐
│ File Filter │ ── Validate MIME type
└────────┬────────┘
│
▼
┌─────────────────┐
│ Sharp Process │ ── Resize + WebP conversion
└────────┬────────┘
│
├── Local: Save to uploads/
└── Cloudinary: Upload to cloud
│
▼
Return path/URL
Implementation¶
File: backend/src/utils/imageProcessor.js
Configuration¶
// Lines 17-22
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.resolve(__dirname, '../../../uploads');
const MAX_WIDTH = 1200; // Max image width in pixels
const WEBP_QUALITY = 80; // WebP compression quality
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB max
const MAX_FILES = 10; // Max 10 images per request
const STORAGE_PROVIDER = process.env.STORAGE_PROVIDER || 'local';
Allowed MIME Types¶
// Lines 24-30
const ALLOWED_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/avif',
'image/tiff',
]);
Filename Generation¶
// Lines 40-47
function generateFilename(originalname) {
const timestamp = Date.now();
const random = crypto.randomBytes(8).toString('hex');
const base = path.basename(originalname, path.extname(originalname))
.replace(/[^a-zA-Z0-9_-]/g, '_')
.slice(0, 64);
return `${timestamp}-${random}-${base}.webp`;
}
Format: {timestamp}-{random}-{sanitized_name}.webp
Local Storage Processing¶
File: imageProcessor.js:67-119
async function processSingleImageLocal(file) {
// 1. Verify file exists and is not empty
// 2. Read image metadata with Sharp
// 3. Resize to max 1200px width (without enlargement)
// 4. Convert to WebP at quality 80
// 5. Save to uploads directory
// 6. Delete temp file
// 7. Return file info
}
Sharp Pipeline:
await sharp(inputPath)
.resize({
width: MAX_WIDTH, // 1200px
withoutEnlargement: true, // Don't upscale small images
fit: 'inside', // Maintain aspect ratio
})
.webp({ quality: WEBP_QUALITY }) // Quality 80
.toFile(outputPath);
Cloudinary Storage Processing¶
File: imageProcessor.js:121-146
async function processSingleImageCloudinary(file) {
// 1. Upload to Cloudinary
// 2. Delete temp file
// 3. Return Cloudinary URL and metadata
}
Storage Selection¶
File: imageProcessor.js:148-153
async function processSingleImage(file) {
if (STORAGE_PROVIDER === 'cloudinary') {
return processSingleImageCloudinary(file);
}
return processSingleImageLocal(file);
}
Batch Processing¶
File: imageProcessor.js:155-201
export async function processListingImages(files) {
// Process all images in parallel
const outcomes = await Promise.allSettled(tasks);
// Collect successful results and errors
// If all fail, throw error
// Return array of file paths
}
Middleware¶
Upload Middleware¶
File: imageProcessor.js:275-284
export const uploadMiddleware = multer({
storage, // Disk storage with temp filenames
fileFilter, // MIME type validation
limits: {
fileSize: MAX_FILE_SIZE, // 10MB
files: MAX_FILES, // 10 files
},
});
export const processImages = uploadMiddleware.array('images', MAX_FILES);
Optimization Middleware¶
File: imageProcessor.js:286-298
export async function optimizeImages(req, res, next) {
if (!req.files || req.files.length === 0) {
req.images = [];
return next();
}
req.images = await processListingImages(req.files);
next();
}
Profile Image Processing¶
File: imageProcessor.js:300-314
export async function processProfileImage(file) {
// Single image processing for profile pictures
// Same pipeline but returns single path
}
Usage in Routes¶
Listing Routes¶
File: backend/src/modules/properties/listing.routes.js:18-23
const protectedWriteMiddleware = [
authenticate(),
processImages, // Multer upload
optimizeImages, // Sharp processing
validateListingAttributes(),
];
router.post('/', ...protectedWriteMiddleware, createListing);
router.patch('/:id', ...protectedWriteMiddleware, updateListing);
User Routes¶
File: backend/src/modules/users/user.routes.js:41
Image Cleanup¶
Listing Update¶
File: backend/src/modules/properties/listing.controller.js:218-222
// Old images cleaned up when new images uploaded
if (req.images && req.images.length > 0 && existing.images.length > 0) {
cleanupImages(existing.images).catch((err) => {
console.error('[Listing] Failed to cleanup old images:', err.message);
});
}
Listing Delete¶
File: listing.controller.js:276-280
// All images cleaned up on listing deletion
if (existing.images && existing.images.length > 0) {
cleanupImages(existing.images).catch((err) => {
console.error('[Listing] Failed to cleanup images:', err.message);
});
}
Cleanup Function¶
File: imageProcessor.js:203-228
export async function cleanupImages(filePaths) {
if (STORAGE_PROVIDER === 'cloudinary') {
// Delete from Cloudinary in batch
const publicIds = filePaths.map(extractPublicId).filter(Boolean);
await deleteFromCloudinaryBatch(publicIds);
return;
}
// Delete local files
await Promise.all(
filePaths.map(async (relativePath) => {
const fullPath = path.resolve(projectRoot, relativePath);
await fs.unlink(fullPath).catch(() => {});
}),
);
}
Temp File Cleanup¶
File: imageProcessor.js:230-243
async function cleanupTempFiles() {
if (STORAGE_PROVIDER !== 'cloudinary') return;
// Clean up temp files from failed uploads
const files = await fs.readdir(dir);
for (const file of files) {
if (file.startsWith('temp-')) {
await fs.unlink(path.join(dir, file));
}
}
}
// Runs on startup
cleanupTempFiles();
Error Handling¶
| Error | HTTP Status | Message |
|---|---|---|
| Invalid MIME type | 400 | "File type not allowed" |
| File too large | 400 | "Upload error: File too large" |
| Too many files | 400 | "Upload error: Too many files" |
| Corrupted image | 400 | "Unsupported or corrupted image format" |
| Processing failed | 500 | "Failed to process image" |
| All images failed | 500 | "All image processing failed" |
Configuration¶
Environment Variables¶
STORAGE_PROVIDER=local # or "cloudinary"
UPLOAD_DIR=./uploads # Local storage path
CLOUDINARY_CLOUD_NAME=xxx # Cloudinary config
CLOUDINARY_API_KEY=xxx
CLOUDINARY_API_SECRET=xxx
CLOUDINARY_FOLDER=listings
Storage Options¶
| Provider | Pros | Cons |
|---|---|---|
| Local | Simple, no external dependency | Requires file serving setup |
| Cloudinary | CDN, transforms, backups | External dependency, cost |