/** * Image Storage Utility * * Downloads and stores product images to MinIO/S3 with proper hierarchy. * Images are downloaded ONCE on first discovery/refresh, then served from our CDN. * * Storage structure in MinIO: * cannaiq/images/products/////image-.webp * cannaiq/images/brands//logo.webp * * Public URL format: * https://cdn.cannabrands.app/cannaiq/images/products/az/store/brand/123/image-abc123.webp */ import axios from 'axios'; // @ts-ignore - sharp module typing quirk const sharp = require('sharp'); import { createHash } from 'crypto'; import * as Minio from 'minio'; // MinIO configuration const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'cdn.cannabrands.app'; const MINIO_PORT = parseInt(process.env.MINIO_PORT || '443'); const MINIO_USE_SSL = process.env.MINIO_USE_SSL !== 'false'; const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY; const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY; const MINIO_BUCKET = process.env.MINIO_BUCKET || 'cannaiq'; const MINIO_PUBLIC_URL = process.env.MINIO_PUBLIC_ENDPOINT || 'https://cdn.cannabrands.app'; // Check if MinIO is configured 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 { original: string; // URL path to original image 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 } /** * 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 */ function hashUrl(url: string): string { return createHash('md5').update(url).digest('hex').substring(0, 8); } /** * Build the MinIO object key for a product image * Structure: images/products/////image-.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 */ async function downloadImage(imageUrl: string): Promise { const response = await axios.get(imageUrl, { responseType: 'arraybuffer', timeout: 30000, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8', }, }); return Buffer.from(response.data); } /** * Check if an image already exists in MinIO */ async function imageExistsInMinIO(key: string): Promise { 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 { 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 * * @param imageUrl - The source image URL (e.g., from Dutchie CDN) * @param ctx - Product context (state, store, brand, product) * @param options - Download options * @returns Download result with our CDN URLs */ export async function downloadProductImage( imageUrl: string, ctx: ProductImageContext, options: { skipIfExists?: boolean } = {} ): Promise { const { skipIfExists = true } = options; try { if (!imageUrl) { return { success: false, error: 'No image URL provided' }; } if (!useMinIO) { return { success: false, error: 'MinIO not configured - images require MinIO storage' }; } const urlHash = hashUrl(imageUrl); const key = buildImageKey(ctx, urlHash); // Check if image already exists in MinIO if (skipIfExists) { const exists = await imageExistsInMinIO(key); if (exists) { const url = keyToUrl(key); return { success: true, skipped: true, urls: { original: url, full: url, medium: url, thumb: url, }, localPaths: { original: key, full: key, medium: key, thumb: key, }, }; } } // Download the image from source (e.g., Dutchie CDN) const buffer = await downloadImage(imageUrl); // Convert to webp for consistency and compression const webpBuffer = await sharp(buffer) .webp({ quality: 90 }) .toBuffer(); // Upload to MinIO await uploadToMinIO(webpBuffer, key); const url = keyToUrl(key); return { success: true, urls: { original: url, full: url, medium: url, thumb: url, }, localPaths: { original: key, full: key, medium: key, thumb: key, }, bytesDownloaded: webpBuffer.length, }; } catch (error: any) { return { success: false, error: error.message || 'Unknown error', }; } } /** * Download a brand logo */ export async function downloadBrandLogo( imageUrl: string, brandSlug: string, options: { skipIfExists?: boolean } = {} ): Promise { const { skipIfExists = true } = options; try { if (!imageUrl) { return { success: false, error: 'No image URL provided' }; } if (!useMinIO) { return { success: false, error: 'MinIO not configured - images require MinIO storage' }; } const slug = slugify(brandSlug); const key = `images/brands/${slug}/logo.webp`; // Check if logo already exists if (skipIfExists) { const exists = await imageExistsInMinIO(key); if (exists) { const url = keyToUrl(key); return { success: true, skipped: true, urls: { original: url, full: url, medium: url, thumb: url, }, }; } } // Download and process const buffer = await downloadImage(imageUrl); const webpBuffer = await sharp(buffer) .webp({ quality: 90 }) .toBuffer(); // Upload to MinIO await uploadToMinIO(webpBuffer, key); const url = keyToUrl(key); return { success: true, urls: { original: url, full: url, medium: url, thumb: url, }, bytesDownloaded: webpBuffer.length, }; } catch (error: any) { return { success: false, error: error.message || 'Unknown error', }; } } /** * Check if a product image already exists */ export async function productImageExists( imageUrl: string, ctx: ProductImageContext ): Promise { if (!useMinIO) return false; const urlHash = hashUrl(imageUrl); const key = buildImageKey(ctx, urlHash); return imageExistsInMinIO(key); } // Track whether image storage is available let imageStorageReady = false; export function isImageStorageReady(): boolean { return imageStorageReady; } /** * Initialize image storage */ export async function initializeImageStorage(): Promise { 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 { const client = getMinioClient(); if (!client) { 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; } catch (error: any) { console.warn(`[ImageStorage] WARNING: Could not initialize MinIO: ${error.message}`); console.warn('[ImageStorage] Image downloads will be disabled'); imageStorageReady = false; } } /** * Get storage stats (placeholder - would need to list objects) */ export async function getStorageStats(): Promise<{ basePath: string; productCount: number; brandCount: number; totalSizeBytes: number; }> { return { basePath: `${MINIO_ENDPOINT}/${MINIO_BUCKET}/images`, productCount: 0, // Would need to list objects brandCount: 0, totalSizeBytes: 0, }; }