Files
cannaiq/backend/src/utils/image-storage.ts
Kelly 268429b86c 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>
2025-12-13 03:24:22 -07:00

387 lines
9.9 KiB
TypeScript

/**
* 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/<state>/<store_slug>/<brand_slug>/<product_id>/image-<hash>.webp
* cannaiq/images/brands/<brand_slug>/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/<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
*/
async function downloadImage(imageUrl: string): Promise<Buffer> {
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<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
*
* @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<DownloadResult> {
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<DownloadResult> {
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<boolean> {
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<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 {
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,
};
}