- Store product images locally with hierarchy: /images/products/<state>/<store>/<brand>/<product>/ - Add /img/* proxy endpoint for on-demand resizing via Sharp - Implement per-product image checking to skip existing downloads - Fix pathToUrl() to correctly generate /images/... URLs - Add frontend getImageUrl() helper with preset sizes (thumb, medium, large) - Update all product pages to use optimized image URLs - Add stealth session support for Dutchie GraphQL crawls - Include test scripts for crawl and image verification 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
215 lines
6.2 KiB
TypeScript
215 lines
6.2 KiB
TypeScript
/**
|
|
* Image Proxy Route
|
|
*
|
|
* On-demand image resizing service. Serves images with URL-based transforms.
|
|
*
|
|
* Usage:
|
|
* /img/<path>?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;
|