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:
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user