feat: Use MinIO for permanent product image storage

- Rewrite image-storage.ts to use MinIO instead of ephemeral local filesystem
- Images downloaded ONCE from Dutchie CDN, stored permanently in MinIO
- Check MinIO before downloading (skipIfExists) to avoid re-downloads
- Convert images to webp before storage
- Storage path: images/products/<state>/<store>/<brand>/<product>/image-<hash>.webp
- Public URL: https://cdn.cannabrands.app/cannaiq/images/...

This fixes the 2.4GB bandwidth issue from repeatedly downloading images
that were lost when K8s pods restarted.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-13 03:24:22 -07:00
parent 5c08135007
commit 268429b86c

View File

@@ -1,45 +1,54 @@
/** /**
* Local Image Storage Utility * Image Storage Utility
* *
* Downloads and stores product images to local filesystem with proper hierarchy. * Downloads and stores product images to MinIO/S3 with proper hierarchy.
* Images are downloaded ONCE on first discovery/refresh, then served from our CDN.
* *
* Directory structure: * Storage structure in MinIO:
* /images/products/<state>/<store_slug>/<brand_slug>/<product_id>/image.webp * cannaiq/images/products/<state>/<store_slug>/<brand_slug>/<product_id>/image-<hash>.webp
* /images/products/<state>/<store_slug>/<brand_slug>/<product_id>/image-medium.webp * cannaiq/images/brands/<brand_slug>/logo.webp
* /images/products/<state>/<store_slug>/<brand_slug>/<product_id>/image-thumb.webp
* /images/brands/<brand_slug>/logo.webp
* *
* This structure allows: * Public URL format:
* - Easy migration to MinIO/S3 (bucket per state) * https://cdn.cannabrands.app/cannaiq/images/products/az/store/brand/123/image-abc123.webp
* - Browsing by state/store/brand
* - Multiple images per product (future: gallery)
*/ */
import axios from 'axios'; import axios from 'axios';
// @ts-ignore - sharp module typing quirk // @ts-ignore - sharp module typing quirk
const sharp = require('sharp'); const sharp = require('sharp');
import * as fs from 'fs/promises';
import * as path from 'path';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import * as Minio from 'minio';
// Base path for image storage - configurable via env // MinIO configuration
function getImagesBasePath(): string { const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'cdn.cannabrands.app';
if (process.env.IMAGES_PATH) { const MINIO_PORT = parseInt(process.env.MINIO_PORT || '443');
return process.env.IMAGES_PATH; const MINIO_USE_SSL = process.env.MINIO_USE_SSL !== 'false';
} const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY;
if (process.env.STORAGE_BASE_PATH) { const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY;
return path.join(process.env.STORAGE_BASE_PATH, 'images'); const MINIO_BUCKET = process.env.MINIO_BUCKET || 'cannaiq';
} const MINIO_PUBLIC_URL = process.env.MINIO_PUBLIC_ENDPOINT || 'https://cdn.cannabrands.app';
return './storage/images';
}
const IMAGES_BASE_PATH = getImagesBasePath();
// Public URL base for serving images // Check if MinIO is configured
const IMAGES_PUBLIC_URL = process.env.IMAGES_PUBLIC_URL || '/images'; const useMinIO = !!(MINIO_ENDPOINT && MINIO_ACCESS_KEY && MINIO_SECRET_KEY);
let minioClient: Minio.Client | null = null;
function getMinioClient(): Minio.Client | null {
if (!useMinIO) return null;
if (!minioClient) {
minioClient = new Minio.Client({
endPoint: MINIO_ENDPOINT,
port: MINIO_PORT,
useSSL: MINIO_USE_SSL,
accessKey: MINIO_ACCESS_KEY!,
secretKey: MINIO_SECRET_KEY!,
});
}
return minioClient;
}
export interface LocalImageSizes { export interface LocalImageSizes {
original: string; // URL path to original image original: string; // URL path to original image
// Legacy compatibility - all point to original until we add image proxy
full: string; full: string;
medium: string; medium: string;
thumb: string; thumb: string;
@@ -62,17 +71,6 @@ export interface ProductImageContext {
dispensaryId?: number; // For backwards compat dispensaryId?: number; // For backwards compat
} }
/**
* Ensure a directory exists
*/
async function ensureDir(dirPath: string): Promise<void> {
try {
await fs.mkdir(dirPath, { recursive: true });
} catch (error: any) {
if (error.code !== 'EEXIST') throw error;
}
}
/** /**
* Sanitize a string for use in file paths * Sanitize a string for use in file paths
*/ */
@@ -91,6 +89,26 @@ function hashUrl(url: string): string {
return createHash('md5').update(url).digest('hex').substring(0, 8); return createHash('md5').update(url).digest('hex').substring(0, 8);
} }
/**
* Build the MinIO object key for a product image
* Structure: images/products/<state>/<store>/<brand>/<product>/image-<hash>.webp
*/
function buildImageKey(ctx: ProductImageContext, urlHash: string): 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 `images/products/${state}/${store}/${brand}/${product}/image-${urlHash}.webp`;
}
/**
* Convert MinIO object key to public URL
*/
function keyToUrl(key: string): string {
return `${MINIO_PUBLIC_URL}/${MINIO_BUCKET}/${key}`;
}
/** /**
* Download an image from a URL and return the buffer * Download an image from a URL and return the buffer
*/ */
@@ -107,73 +125,43 @@ async function downloadImage(imageUrl: string): Promise<Buffer> {
} }
/** /**
* Process and save original image (convert to webp for consistency) * Check if an image already exists in MinIO
*/
async function imageExistsInMinIO(key: string): Promise<boolean> {
const client = getMinioClient();
if (!client) return false;
try {
await client.statObject(MINIO_BUCKET, key);
return true;
} catch (error: any) {
if (error.code === 'NotFound') return false;
// For other errors, assume it doesn't exist
return false;
}
}
/**
* Upload image buffer to MinIO
*/
async function uploadToMinIO(buffer: Buffer, key: string): Promise<void> {
const client = getMinioClient();
if (!client) {
throw new Error('MinIO not configured');
}
await client.putObject(MINIO_BUCKET, key, buffer, buffer.length, {
'Content-Type': 'image/webp',
});
}
/**
* Download and store a product image
* *
* We store only the original - resizing will be done on-demand via * @param imageUrl - The source image URL (e.g., from Dutchie CDN)
* an image proxy service (imgproxy, thumbor, or similar) in the future.
*/
async function processAndSaveImage(
buffer: Buffer,
outputDir: string,
baseFilename: string
): Promise<{ original: string; totalBytes: number }> {
await ensureDir(outputDir);
const originalPath = path.join(outputDir, `${baseFilename}.webp`);
// Convert to webp, preserve original dimensions, high quality
const originalBuffer = await sharp(buffer)
.webp({ quality: 90 })
.toBuffer();
await fs.writeFile(originalPath, originalBuffer);
return {
original: originalPath,
totalBytes: originalBuffer.length,
};
}
/**
* 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}`;
}
/**
* 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 ctx - Product context (state, store, brand, product) * @param ctx - Product context (state, store, brand, product)
* @param options - Download options * @param options - Download options
* @returns Download result with local URLs * @returns Download result with our CDN URLs
*/ */
export async function downloadProductImage( export async function downloadProductImage(
imageUrl: string, imageUrl: string,
@@ -187,17 +175,18 @@ export async function downloadProductImage(
return { success: false, error: 'No image URL provided' }; return { success: false, error: 'No image URL provided' };
} }
const outputDir = buildProductImagePath(ctx); if (!useMinIO) {
const urlHash = hashUrl(imageUrl); return { success: false, error: 'MinIO not configured - images require MinIO storage' };
const baseFilename = `image-${urlHash}`; }
// Check if image already exists const urlHash = hashUrl(imageUrl);
const key = buildImageKey(ctx, urlHash);
// Check if image already exists in MinIO
if (skipIfExists) { if (skipIfExists) {
const existingPath = path.join(outputDir, `${baseFilename}.webp`); const exists = await imageExistsInMinIO(key);
try { if (exists) {
await fs.access(existingPath); const url = keyToUrl(key);
// Image exists, return existing URL
const url = pathToUrl(existingPath);
return { return {
success: true, success: true,
skipped: true, skipped: true,
@@ -208,23 +197,27 @@ export async function downloadProductImage(
thumb: url, thumb: url,
}, },
localPaths: { localPaths: {
original: existingPath, original: key,
full: existingPath, full: key,
medium: existingPath, medium: key,
thumb: existingPath, thumb: key,
}, },
}; };
} catch {
// Image doesn't exist, continue to download
} }
} }
// Download the image // Download the image from source (e.g., Dutchie CDN)
const buffer = await downloadImage(imageUrl); const buffer = await downloadImage(imageUrl);
// Process and save (original only) // Convert to webp for consistency and compression
const result = await processAndSaveImage(buffer, outputDir, baseFilename); const webpBuffer = await sharp(buffer)
const url = pathToUrl(result.original); .webp({ quality: 90 })
.toBuffer();
// Upload to MinIO
await uploadToMinIO(webpBuffer, key);
const url = keyToUrl(key);
return { return {
success: true, success: true,
@@ -235,111 +228,86 @@ export async function downloadProductImage(
thumb: url, thumb: url,
}, },
localPaths: { localPaths: {
original: result.original, original: key,
full: result.original, full: key,
medium: result.original, medium: key,
thumb: result.original, thumb: key,
}, },
bytesDownloaded: result.totalBytes, bytesDownloaded: webpBuffer.length,
}; };
} catch (error: any) { } catch (error: any) {
return { return {
success: false, success: false,
error: error.message || 'Failed to download image', error: error.message || 'Unknown error',
}; };
} }
} }
/** /**
* Legacy function - backwards compatible with old signature * Download a brand logo
* 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 brandSlug - The brand slug/ID
* @returns Download result with local URL
*/ */
export async function downloadBrandLogo( export async function downloadBrandLogo(
logoUrl: string, imageUrl: string,
brandSlug: string, brandSlug: string,
options: { skipIfExists?: boolean } = {} options: { skipIfExists?: boolean } = {}
): Promise<DownloadResult> { ): Promise<DownloadResult> {
const { skipIfExists = true } = options; const { skipIfExists = true } = options;
try { try {
if (!logoUrl) { if (!imageUrl) {
return { success: false, error: 'No logo URL provided' }; return { success: false, error: 'No image URL provided' };
} }
const safeBrandSlug = slugify(brandSlug); if (!useMinIO) {
const outputDir = path.join(IMAGES_BASE_PATH, 'brands', safeBrandSlug); return { success: false, error: 'MinIO not configured - images require MinIO storage' };
const urlHash = hashUrl(logoUrl); }
const baseFilename = `logo-${urlHash}`;
const slug = slugify(brandSlug);
const key = `images/brands/${slug}/logo.webp`;
// Check if logo already exists // Check if logo already exists
if (skipIfExists) { if (skipIfExists) {
const existingPath = path.join(outputDir, `${baseFilename}.webp`); const exists = await imageExistsInMinIO(key);
try { if (exists) {
await fs.access(existingPath); const url = keyToUrl(key);
return { return {
success: true, success: true,
skipped: true, skipped: true,
urls: { urls: {
original: pathToUrl(existingPath), original: url,
full: pathToUrl(existingPath), full: url,
medium: pathToUrl(existingPath), medium: url,
thumb: pathToUrl(existingPath), thumb: url,
}, },
}; };
} catch {
// Logo doesn't exist, continue
} }
} }
// Download the image // Download and process
const buffer = await downloadImage(logoUrl); const buffer = await downloadImage(imageUrl);
const webpBuffer = await sharp(buffer)
// Brand logos in their own directory .webp({ quality: 90 })
await ensureDir(outputDir);
const logoPath = path.join(outputDir, `${baseFilename}.webp`);
const logoBuffer = await sharp(buffer)
.resize(400, 400, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 85 })
.toBuffer(); .toBuffer();
await fs.writeFile(logoPath, logoBuffer); // Upload to MinIO
await uploadToMinIO(webpBuffer, key);
const url = keyToUrl(key);
return { return {
success: true, success: true,
urls: { urls: {
original: pathToUrl(logoPath), original: url,
full: pathToUrl(logoPath), full: url,
medium: pathToUrl(logoPath), medium: url,
thumb: pathToUrl(logoPath), thumb: url,
}, },
bytesDownloaded: logoBuffer.length, bytesDownloaded: webpBuffer.length,
}; };
} catch (error: any) { } catch (error: any) {
return { return {
success: false, success: false,
error: error.message || 'Failed to download brand logo', error: error.message || 'Unknown error',
}; };
} }
} }
@@ -348,44 +316,14 @@ export async function downloadBrandLogo(
* Check if a product image already exists * Check if a product image already exists
*/ */
export async function productImageExists( export async function productImageExists(
ctx: ProductImageContext, imageUrl: string,
imageUrl: string ctx: ProductImageContext
): Promise<boolean> { ): Promise<boolean> {
const outputDir = buildProductImagePath(ctx); if (!useMinIO) return false;
const urlHash = hashUrl(imageUrl); const urlHash = hashUrl(imageUrl);
const imagePath = path.join(outputDir, `image-${urlHash}.webp`); const key = buildImageKey(ctx, urlHash);
return imageExistsInMinIO(key);
try {
await fs.access(imagePath);
return true;
} catch {
return false;
}
}
/**
* Get the local image URL for a product (if exists)
*/
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 {
await fs.access(imagePath);
const url = pathToUrl(imagePath);
return {
original: url,
full: url,
medium: url,
thumb: url,
};
} catch {
return null;
}
} }
// Track whether image storage is available // Track whether image storage is available
@@ -396,23 +334,42 @@ export function isImageStorageReady(): boolean {
} }
/** /**
* Initialize the image storage directories * Initialize image storage
*/ */
export async function initializeImageStorage(): Promise<void> { export async function initializeImageStorage(): Promise<void> {
if (!useMinIO) {
console.warn('[ImageStorage] MinIO not configured (MINIO_ENDPOINT/ACCESS_KEY/SECRET_KEY required)');
console.warn('[ImageStorage] Image downloads will be disabled');
imageStorageReady = false;
return;
}
try { try {
await ensureDir(path.join(IMAGES_BASE_PATH, 'products')); const client = getMinioClient();
await ensureDir(path.join(IMAGES_BASE_PATH, 'brands')); if (!client) {
console.log(`[ImageStorage] Initialized at ${IMAGES_BASE_PATH}`); throw new Error('Failed to create MinIO client');
}
// Verify bucket exists and is accessible
const exists = await client.bucketExists(MINIO_BUCKET);
if (!exists) {
console.warn(`[ImageStorage] Bucket ${MINIO_BUCKET} does not exist`);
imageStorageReady = false;
return;
}
console.log(`[ImageStorage] Initialized with MinIO: ${MINIO_ENDPOINT}/${MINIO_BUCKET}`);
console.log(`[ImageStorage] Public URL: ${MINIO_PUBLIC_URL}/${MINIO_BUCKET}/images/...`);
imageStorageReady = true; imageStorageReady = true;
} catch (error: any) { } catch (error: any) {
console.warn(`[ImageStorage] WARNING: Could not initialize at ${IMAGES_BASE_PATH}: ${error.message}`); console.warn(`[ImageStorage] WARNING: Could not initialize MinIO: ${error.message}`);
console.warn(' Image features disabled. Server will continue without image downloads.'); console.warn('[ImageStorage] Image downloads will be disabled');
imageStorageReady = false; imageStorageReady = false;
} }
} }
/** /**
* Get storage stats * Get storage stats (placeholder - would need to list objects)
*/ */
export async function getStorageStats(): Promise<{ export async function getStorageStats(): Promise<{
basePath: string; basePath: string;
@@ -420,38 +377,10 @@ export async function getStorageStats(): Promise<{
brandCount: number; brandCount: number;
totalSizeBytes: number; totalSizeBytes: number;
}> { }> {
let productCount = 0;
let brandCount = 0;
let totalSizeBytes = 0;
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 };
}
const products = await countDir(path.join(IMAGES_BASE_PATH, 'products'));
const brands = await countDir(path.join(IMAGES_BASE_PATH, 'brands'));
return { return {
basePath: IMAGES_BASE_PATH, basePath: `${MINIO_ENDPOINT}/${MINIO_BUCKET}/images`,
productCount: products.count, productCount: 0, // Would need to list objects
brandCount: brands.count, brandCount: 0,
totalSizeBytes: products.size + brands.size, totalSizeBytes: 0,
}; };
} }