feat(images): Add local image storage with on-demand resizing
- 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>
This commit is contained in:
@@ -1,26 +1,29 @@
|
||||
/**
|
||||
* Local Image Storage Utility
|
||||
*
|
||||
* Downloads and stores product images to local filesystem.
|
||||
* Replaces MinIO-based storage with simple local file storage.
|
||||
* Downloads and stores product images to local filesystem with proper hierarchy.
|
||||
*
|
||||
* Directory structure:
|
||||
* /images/products/<dispensary_id>/<product_id>.webp
|
||||
* /images/products/<dispensary_id>/<product_id>-thumb.webp
|
||||
* /images/products/<dispensary_id>/<product_id>-medium.webp
|
||||
* /images/brands/<brand_slug>.webp
|
||||
* /images/products/<state>/<store_slug>/<brand_slug>/<product_id>/image.webp
|
||||
* /images/products/<state>/<store_slug>/<brand_slug>/<product_id>/image-medium.webp
|
||||
* /images/products/<state>/<store_slug>/<brand_slug>/<product_id>/image-thumb.webp
|
||||
* /images/brands/<brand_slug>/logo.webp
|
||||
*
|
||||
* This structure allows:
|
||||
* - Easy migration to MinIO/S3 (bucket per state)
|
||||
* - Browsing by state/store/brand
|
||||
* - Multiple images per product (future: gallery)
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import sharp from 'sharp';
|
||||
// @ts-ignore - sharp module typing quirk
|
||||
const sharp = require('sharp');
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
// Base path for image storage - configurable via env
|
||||
// Uses project-relative paths by default, NOT /app or other privileged paths
|
||||
function getImagesBasePath(): string {
|
||||
// Priority: IMAGES_PATH > STORAGE_BASE_PATH/images > ./storage/images
|
||||
if (process.env.IMAGES_PATH) {
|
||||
return process.env.IMAGES_PATH;
|
||||
}
|
||||
@@ -35,16 +38,28 @@ const IMAGES_BASE_PATH = getImagesBasePath();
|
||||
const IMAGES_PUBLIC_URL = process.env.IMAGES_PUBLIC_URL || '/images';
|
||||
|
||||
export interface LocalImageSizes {
|
||||
full: string; // URL path: /images/products/123/456.webp
|
||||
medium: string; // URL path: /images/products/123/456-medium.webp
|
||||
thumb: string; // URL path: /images/products/123/456-thumb.webp
|
||||
original: string; // URL path to original image
|
||||
// Legacy compatibility - all point to original until we add image proxy
|
||||
full: string;
|
||||
medium: string;
|
||||
thumb: string;
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
success: boolean;
|
||||
urls?: LocalImageSizes;
|
||||
localPaths?: LocalImageSizes;
|
||||
error?: string;
|
||||
bytesDownloaded?: number;
|
||||
skipped?: boolean; // True if image already exists
|
||||
}
|
||||
|
||||
export interface ProductImageContext {
|
||||
stateCode: string; // e.g., "AZ", "CA"
|
||||
storeSlug: string; // e.g., "deeply-rooted"
|
||||
brandSlug: string; // e.g., "high-west-farms"
|
||||
productId: string; // External product ID
|
||||
dispensaryId?: number; // For backwards compat
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +73,17 @@ async function ensureDir(dirPath: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a string for use in file paths
|
||||
*/
|
||||
function slugify(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.substring(0, 50) || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short hash from a URL for deduplication
|
||||
*/
|
||||
@@ -81,53 +107,30 @@ async function downloadImage(imageUrl: string): Promise<Buffer> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and save image in multiple sizes
|
||||
* Returns the file paths relative to IMAGES_BASE_PATH
|
||||
* Process and save original image (convert to webp for consistency)
|
||||
*
|
||||
* We store only the original - resizing will be done on-demand via
|
||||
* an image proxy service (imgproxy, thumbor, or similar) in the future.
|
||||
*/
|
||||
async function processAndSaveImage(
|
||||
buffer: Buffer,
|
||||
outputDir: string,
|
||||
baseFilename: string
|
||||
): Promise<{ full: string; medium: string; thumb: string; totalBytes: number }> {
|
||||
): Promise<{ original: string; totalBytes: number }> {
|
||||
await ensureDir(outputDir);
|
||||
|
||||
const fullPath = path.join(outputDir, `${baseFilename}.webp`);
|
||||
const mediumPath = path.join(outputDir, `${baseFilename}-medium.webp`);
|
||||
const thumbPath = path.join(outputDir, `${baseFilename}-thumb.webp`);
|
||||
const originalPath = path.join(outputDir, `${baseFilename}.webp`);
|
||||
|
||||
// Process images in parallel
|
||||
const [fullBuffer, mediumBuffer, thumbBuffer] = await Promise.all([
|
||||
// Full: max 1200x1200, high quality
|
||||
sharp(buffer)
|
||||
.resize(1200, 1200, { fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 85 })
|
||||
.toBuffer(),
|
||||
// Medium: 600x600
|
||||
sharp(buffer)
|
||||
.resize(600, 600, { fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 80 })
|
||||
.toBuffer(),
|
||||
// Thumb: 200x200
|
||||
sharp(buffer)
|
||||
.resize(200, 200, { fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 75 })
|
||||
.toBuffer(),
|
||||
]);
|
||||
// Convert to webp, preserve original dimensions, high quality
|
||||
const originalBuffer = await sharp(buffer)
|
||||
.webp({ quality: 90 })
|
||||
.toBuffer();
|
||||
|
||||
// Save all sizes
|
||||
await Promise.all([
|
||||
fs.writeFile(fullPath, fullBuffer),
|
||||
fs.writeFile(mediumPath, mediumBuffer),
|
||||
fs.writeFile(thumbPath, thumbBuffer),
|
||||
]);
|
||||
|
||||
const totalBytes = fullBuffer.length + mediumBuffer.length + thumbBuffer.length;
|
||||
await fs.writeFile(originalPath, originalBuffer);
|
||||
|
||||
return {
|
||||
full: fullPath,
|
||||
medium: mediumPath,
|
||||
thumb: thumbPath,
|
||||
totalBytes,
|
||||
original: originalPath,
|
||||
totalBytes: originalBuffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,47 +138,107 @@ async function processAndSaveImage(
|
||||
* Convert a file path to a public URL
|
||||
*/
|
||||
function pathToUrl(filePath: string): string {
|
||||
// Find /products/ or /brands/ in the path and extract from there
|
||||
const productsMatch = filePath.match(/(\/products\/.*)/);
|
||||
const brandsMatch = filePath.match(/(\/brands\/.*)/);
|
||||
|
||||
if (productsMatch) {
|
||||
return `${IMAGES_PUBLIC_URL}${productsMatch[1]}`;
|
||||
}
|
||||
if (brandsMatch) {
|
||||
return `${IMAGES_PUBLIC_URL}${brandsMatch[1]}`;
|
||||
}
|
||||
|
||||
// Fallback: try to replace base path (works if paths match exactly)
|
||||
const relativePath = filePath.replace(IMAGES_BASE_PATH, '');
|
||||
return `${IMAGES_PUBLIC_URL}${relativePath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and store a product image locally
|
||||
* Build the directory path for a product image
|
||||
* Structure: /images/products/<state>/<store>/<brand>/<product>/
|
||||
*/
|
||||
function buildProductImagePath(ctx: ProductImageContext): string {
|
||||
const state = slugify(ctx.stateCode || 'unknown');
|
||||
const store = slugify(ctx.storeSlug || 'unknown');
|
||||
const brand = slugify(ctx.brandSlug || 'unknown');
|
||||
const product = slugify(ctx.productId || 'unknown');
|
||||
|
||||
return path.join(IMAGES_BASE_PATH, 'products', state, store, brand, product);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and store a product image with proper hierarchy
|
||||
*
|
||||
* @param imageUrl - The third-party image URL to download
|
||||
* @param dispensaryId - The dispensary ID (for directory organization)
|
||||
* @param productId - The product ID or external ID (for filename)
|
||||
* @param ctx - Product context (state, store, brand, product)
|
||||
* @param options - Download options
|
||||
* @returns Download result with local URLs
|
||||
*/
|
||||
export async function downloadProductImage(
|
||||
imageUrl: string,
|
||||
dispensaryId: number,
|
||||
productId: string | number
|
||||
ctx: ProductImageContext,
|
||||
options: { skipIfExists?: boolean } = {}
|
||||
): Promise<DownloadResult> {
|
||||
const { skipIfExists = true } = options;
|
||||
|
||||
try {
|
||||
if (!imageUrl) {
|
||||
return { success: false, error: 'No image URL provided' };
|
||||
}
|
||||
|
||||
const outputDir = buildProductImagePath(ctx);
|
||||
const urlHash = hashUrl(imageUrl);
|
||||
const baseFilename = `image-${urlHash}`;
|
||||
|
||||
// Check if image already exists
|
||||
if (skipIfExists) {
|
||||
const existingPath = path.join(outputDir, `${baseFilename}.webp`);
|
||||
try {
|
||||
await fs.access(existingPath);
|
||||
// Image exists, return existing URL
|
||||
const url = pathToUrl(existingPath);
|
||||
return {
|
||||
success: true,
|
||||
skipped: true,
|
||||
urls: {
|
||||
original: url,
|
||||
full: url,
|
||||
medium: url,
|
||||
thumb: url,
|
||||
},
|
||||
localPaths: {
|
||||
original: existingPath,
|
||||
full: existingPath,
|
||||
medium: existingPath,
|
||||
thumb: existingPath,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
// Image doesn't exist, continue to download
|
||||
}
|
||||
}
|
||||
|
||||
// Download the image
|
||||
const buffer = await downloadImage(imageUrl);
|
||||
|
||||
// Organize by dispensary ID
|
||||
const outputDir = path.join(IMAGES_BASE_PATH, 'products', String(dispensaryId));
|
||||
|
||||
// Use product ID + URL hash for uniqueness
|
||||
const urlHash = hashUrl(imageUrl);
|
||||
const baseFilename = `${productId}-${urlHash}`;
|
||||
|
||||
// Process and save
|
||||
// Process and save (original only)
|
||||
const result = await processAndSaveImage(buffer, outputDir, baseFilename);
|
||||
const url = pathToUrl(result.original);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
urls: {
|
||||
full: pathToUrl(result.full),
|
||||
medium: pathToUrl(result.medium),
|
||||
thumb: pathToUrl(result.thumb),
|
||||
original: url,
|
||||
full: url,
|
||||
medium: url,
|
||||
thumb: url,
|
||||
},
|
||||
localPaths: {
|
||||
original: result.original,
|
||||
full: result.original,
|
||||
medium: result.original,
|
||||
thumb: result.original,
|
||||
},
|
||||
bytesDownloaded: result.totalBytes,
|
||||
};
|
||||
@@ -188,33 +251,70 @@ export async function downloadProductImage(
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and store a brand logo locally
|
||||
* Legacy function - backwards compatible with old signature
|
||||
* Maps to new hierarchy using dispensary_id as store identifier
|
||||
*/
|
||||
export async function downloadProductImageLegacy(
|
||||
imageUrl: string,
|
||||
dispensaryId: number,
|
||||
productId: string | number
|
||||
): Promise<DownloadResult> {
|
||||
return downloadProductImage(imageUrl, {
|
||||
stateCode: 'unknown',
|
||||
storeSlug: `store-${dispensaryId}`,
|
||||
brandSlug: 'unknown',
|
||||
productId: String(productId),
|
||||
dispensaryId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and store a brand logo
|
||||
*
|
||||
* @param logoUrl - The brand logo URL
|
||||
* @param brandId - The brand ID or slug
|
||||
* @param brandSlug - The brand slug/ID
|
||||
* @returns Download result with local URL
|
||||
*/
|
||||
export async function downloadBrandLogo(
|
||||
logoUrl: string,
|
||||
brandId: string
|
||||
brandSlug: string,
|
||||
options: { skipIfExists?: boolean } = {}
|
||||
): Promise<DownloadResult> {
|
||||
const { skipIfExists = true } = options;
|
||||
|
||||
try {
|
||||
if (!logoUrl) {
|
||||
return { success: false, error: 'No logo URL provided' };
|
||||
}
|
||||
|
||||
const safeBrandSlug = slugify(brandSlug);
|
||||
const outputDir = path.join(IMAGES_BASE_PATH, 'brands', safeBrandSlug);
|
||||
const urlHash = hashUrl(logoUrl);
|
||||
const baseFilename = `logo-${urlHash}`;
|
||||
|
||||
// Check if logo already exists
|
||||
if (skipIfExists) {
|
||||
const existingPath = path.join(outputDir, `${baseFilename}.webp`);
|
||||
try {
|
||||
await fs.access(existingPath);
|
||||
return {
|
||||
success: true,
|
||||
skipped: true,
|
||||
urls: {
|
||||
full: pathToUrl(existingPath),
|
||||
medium: pathToUrl(existingPath),
|
||||
thumb: pathToUrl(existingPath),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
// Logo doesn't exist, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Download the image
|
||||
const buffer = await downloadImage(logoUrl);
|
||||
|
||||
// Brand logos go in /images/brands/
|
||||
const outputDir = path.join(IMAGES_BASE_PATH, 'brands');
|
||||
|
||||
// Sanitize brand ID for filename
|
||||
const safeBrandId = brandId.replace(/[^a-zA-Z0-9-_]/g, '_');
|
||||
const urlHash = hashUrl(logoUrl);
|
||||
const baseFilename = `${safeBrandId}-${urlHash}`;
|
||||
|
||||
// Process and save (single size for logos)
|
||||
// Brand logos in their own directory
|
||||
await ensureDir(outputDir);
|
||||
const logoPath = path.join(outputDir, `${baseFilename}.webp`);
|
||||
|
||||
@@ -243,20 +343,16 @@ export async function downloadBrandLogo(
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a local image already exists
|
||||
* Check if a product image already exists
|
||||
*/
|
||||
export async function imageExists(
|
||||
dispensaryId: number,
|
||||
productId: string | number,
|
||||
export async function productImageExists(
|
||||
ctx: ProductImageContext,
|
||||
imageUrl: string
|
||||
): Promise<boolean> {
|
||||
const outputDir = buildProductImagePath(ctx);
|
||||
const urlHash = hashUrl(imageUrl);
|
||||
const imagePath = path.join(
|
||||
IMAGES_BASE_PATH,
|
||||
'products',
|
||||
String(dispensaryId),
|
||||
`${productId}-${urlHash}.webp`
|
||||
);
|
||||
const imagePath = path.join(outputDir, `image-${urlHash}.webp`);
|
||||
|
||||
try {
|
||||
await fs.access(imagePath);
|
||||
return true;
|
||||
@@ -266,24 +362,27 @@ export async function imageExists(
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a product's local images
|
||||
* Get the local image URL for a product (if exists)
|
||||
*/
|
||||
export async function deleteProductImages(
|
||||
dispensaryId: number,
|
||||
productId: string | number,
|
||||
imageUrl?: string
|
||||
): Promise<void> {
|
||||
const productDir = path.join(IMAGES_BASE_PATH, 'products', String(dispensaryId));
|
||||
const prefix = imageUrl
|
||||
? `${productId}-${hashUrl(imageUrl)}`
|
||||
: String(productId);
|
||||
export async function getProductImageUrl(
|
||||
ctx: ProductImageContext,
|
||||
imageUrl: string
|
||||
): Promise<LocalImageSizes | null> {
|
||||
const outputDir = buildProductImagePath(ctx);
|
||||
const urlHash = hashUrl(imageUrl);
|
||||
const imagePath = path.join(outputDir, `image-${urlHash}.webp`);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(productDir);
|
||||
const toDelete = files.filter(f => f.startsWith(prefix));
|
||||
await Promise.all(toDelete.map(f => fs.unlink(path.join(productDir, f))));
|
||||
await fs.access(imagePath);
|
||||
const url = pathToUrl(imagePath);
|
||||
return {
|
||||
original: url,
|
||||
full: url,
|
||||
medium: url,
|
||||
thumb: url,
|
||||
};
|
||||
} catch {
|
||||
// Directory might not exist, that's fine
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,19 +395,17 @@ export function isImageStorageReady(): boolean {
|
||||
|
||||
/**
|
||||
* Initialize the image storage directories
|
||||
* Does NOT throw on failure - logs warning and continues
|
||||
*/
|
||||
export async function initializeImageStorage(): Promise<void> {
|
||||
try {
|
||||
await ensureDir(path.join(IMAGES_BASE_PATH, 'products'));
|
||||
await ensureDir(path.join(IMAGES_BASE_PATH, 'brands'));
|
||||
console.log(`✅ Image storage initialized at ${IMAGES_BASE_PATH}`);
|
||||
console.log(`[ImageStorage] Initialized at ${IMAGES_BASE_PATH}`);
|
||||
imageStorageReady = true;
|
||||
} catch (error: any) {
|
||||
console.warn(`⚠️ WARNING: Could not initialize image storage at ${IMAGES_BASE_PATH}: ${error.message}`);
|
||||
console.warn(' Image upload/processing is disabled. Server will continue without image features.');
|
||||
console.warn(`[ImageStorage] WARNING: Could not initialize at ${IMAGES_BASE_PATH}: ${error.message}`);
|
||||
console.warn(' Image features disabled. Server will continue without image downloads.');
|
||||
imageStorageReady = false;
|
||||
// Do NOT throw - server should still start
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,34 +413,43 @@ export async function initializeImageStorage(): Promise<void> {
|
||||
* Get storage stats
|
||||
*/
|
||||
export async function getStorageStats(): Promise<{
|
||||
productsDir: string;
|
||||
brandsDir: string;
|
||||
basePath: string;
|
||||
productCount: number;
|
||||
brandCount: number;
|
||||
totalSizeBytes: number;
|
||||
}> {
|
||||
const productsDir = path.join(IMAGES_BASE_PATH, 'products');
|
||||
const brandsDir = path.join(IMAGES_BASE_PATH, 'brands');
|
||||
|
||||
let productCount = 0;
|
||||
let brandCount = 0;
|
||||
let totalSizeBytes = 0;
|
||||
|
||||
try {
|
||||
const productDirs = await fs.readdir(productsDir);
|
||||
for (const dir of productDirs) {
|
||||
const files = await fs.readdir(path.join(productsDir, dir));
|
||||
productCount += files.filter(f => f.endsWith('.webp') && !f.includes('-')).length;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
async function countDir(dirPath: string): Promise<{ count: number; size: number }> {
|
||||
let count = 0;
|
||||
let size = 0;
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const sub = await countDir(fullPath);
|
||||
count += sub.count;
|
||||
size += sub.size;
|
||||
} else if (entry.name.endsWith('.webp') && !entry.name.includes('-')) {
|
||||
count++;
|
||||
const stat = await fs.stat(fullPath);
|
||||
size += stat.size;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { count, size };
|
||||
}
|
||||
|
||||
try {
|
||||
const brandFiles = await fs.readdir(brandsDir);
|
||||
brandCount = brandFiles.filter(f => f.endsWith('.webp')).length;
|
||||
} catch { /* ignore */ }
|
||||
const products = await countDir(path.join(IMAGES_BASE_PATH, 'products'));
|
||||
const brands = await countDir(path.join(IMAGES_BASE_PATH, 'brands'));
|
||||
|
||||
return {
|
||||
productsDir,
|
||||
brandsDir,
|
||||
productCount,
|
||||
brandCount,
|
||||
basePath: IMAGES_BASE_PATH,
|
||||
productCount: products.count,
|
||||
brandCount: brands.count,
|
||||
totalSizeBytes: products.size + brands.size,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user