/** * Image Proxy Route * * On-demand image resizing service. Serves images with URL-based transforms. * * Usage: * /img/?w=200&h=200&q=80&fit=cover * * Parameters: * w - width (pixels) * h - height (pixels) * q - quality (1-100, default 80) * fit - resize fit: cover, contain, fill, inside, outside (default: inside) * blur - blur sigma (0.3-1000) * gray - grayscale (1 = enabled) * format - output format: webp, jpeg, png, avif (default: webp) * * Examples: * /img/products/az/store/brand/product/image.webp?w=200 * /img/products/az/store/brand/product/image.webp?w=600&h=400&fit=cover * /img/products/az/store/brand/product/image.webp?w=100&blur=5&gray=1 */ import { Router, Request, Response } from 'express'; import * as fs from 'fs/promises'; import * as path from 'path'; // @ts-ignore const sharp = require('sharp'); const router = Router(); // Base path for images function getImagesBasePath(): string { if (process.env.IMAGES_PATH) { return process.env.IMAGES_PATH; } if (process.env.STORAGE_BASE_PATH) { return path.join(process.env.STORAGE_BASE_PATH, 'images'); } return './storage/images'; } const IMAGES_BASE_PATH = getImagesBasePath(); // Allowed fit modes const ALLOWED_FITS = ['cover', 'contain', 'fill', 'inside', 'outside'] as const; type FitMode = typeof ALLOWED_FITS[number]; // Allowed formats const ALLOWED_FORMATS = ['webp', 'jpeg', 'jpg', 'png', 'avif'] as const; type OutputFormat = typeof ALLOWED_FORMATS[number]; // Cache headers (1 year for immutable content-addressed images) const CACHE_MAX_AGE = 31536000; // 1 year in seconds interface TransformParams { width?: number; height?: number; quality: number; fit: FitMode; blur?: number; grayscale: boolean; format: OutputFormat; } function parseTransformParams(query: any): TransformParams { return { width: query.w ? Math.min(Math.max(parseInt(query.w, 10), 1), 4000) : undefined, height: query.h ? Math.min(Math.max(parseInt(query.h, 10), 1), 4000) : undefined, quality: query.q ? Math.min(Math.max(parseInt(query.q, 10), 1), 100) : 80, fit: ALLOWED_FITS.includes(query.fit) ? query.fit : 'inside', blur: query.blur ? Math.min(Math.max(parseFloat(query.blur), 0.3), 1000) : undefined, grayscale: query.gray === '1' || query.grayscale === '1', format: ALLOWED_FORMATS.includes(query.format) ? query.format : 'webp', }; } function getContentType(format: OutputFormat): string { switch (format) { case 'jpeg': case 'jpg': return 'image/jpeg'; case 'png': return 'image/png'; case 'avif': return 'image/avif'; case 'webp': default: return 'image/webp'; } } /** * Image proxy endpoint * GET /img/* */ router.get('/*', async (req: Request, res: Response) => { try { // Get the image path from URL (everything after /img/) const imagePath = req.params[0]; if (!imagePath) { return res.status(400).json({ error: 'Image path required' }); } // Security: prevent directory traversal const normalizedPath = path.normalize(imagePath).replace(/^(\.\.(\/|\\|$))+/, ''); const basePath = path.resolve(IMAGES_BASE_PATH); const fullPath = path.resolve(path.join(IMAGES_BASE_PATH, normalizedPath)); // Ensure path is within base directory if (!fullPath.startsWith(basePath)) { console.error(`[ImageProxy] Path traversal attempt: ${fullPath} not in ${basePath}`); return res.status(403).json({ error: 'Access denied' }); } // Check if file exists try { await fs.access(fullPath); } catch { return res.status(404).json({ error: 'Image not found' }); } // Parse transform parameters const params = parseTransformParams(req.query); // Check if any transforms are requested const hasTransforms = params.width || params.height || params.blur || params.grayscale; // Read the original image const imageBuffer = await fs.readFile(fullPath); let outputBuffer: Buffer; if (hasTransforms) { // Apply transforms let pipeline = sharp(imageBuffer); // Resize if (params.width || params.height) { pipeline = pipeline.resize(params.width, params.height, { fit: params.fit, withoutEnlargement: true, }); } // Blur if (params.blur) { pipeline = pipeline.blur(params.blur); } // Grayscale if (params.grayscale) { pipeline = pipeline.grayscale(); } // Output format switch (params.format) { case 'jpeg': case 'jpg': pipeline = pipeline.jpeg({ quality: params.quality }); break; case 'png': pipeline = pipeline.png({ quality: params.quality }); break; case 'avif': pipeline = pipeline.avif({ quality: params.quality }); break; case 'webp': default: pipeline = pipeline.webp({ quality: params.quality }); } outputBuffer = await pipeline.toBuffer(); } else { // No transforms - serve original (but maybe convert format) if (params.format !== 'webp' || params.quality !== 80) { let pipeline = sharp(imageBuffer); switch (params.format) { case 'jpeg': case 'jpg': pipeline = pipeline.jpeg({ quality: params.quality }); break; case 'png': pipeline = pipeline.png({ quality: params.quality }); break; case 'avif': pipeline = pipeline.avif({ quality: params.quality }); break; case 'webp': default: pipeline = pipeline.webp({ quality: params.quality }); } outputBuffer = await pipeline.toBuffer(); } else { outputBuffer = imageBuffer; } } // Set headers res.setHeader('Content-Type', getContentType(params.format)); res.setHeader('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, immutable`); res.setHeader('X-Image-Size', outputBuffer.length); // Send image res.send(outputBuffer); } catch (error: any) { console.error('[ImageProxy] Error:', error.message); res.status(500).json({ error: 'Failed to process image' }); } }); export default router;