import { Client as MinioClient } from 'minio'; import { StorageService, FileMetadata, UploadResult } from './StorageService'; export class MinioStorageService implements StorageService { private client: MinioClient; private bucketName: string; private cdnBaseUrl: string; constructor( endpoint: string, accessKey: string, secretKey: string, useSSL: boolean = false, bucketName: string = 'banatie', cdnBaseUrl?: string, ) { // Parse endpoint to separate hostname and port const cleanEndpoint = endpoint.replace(/^https?:\/\//, ''); const [hostname, portStr] = cleanEndpoint.split(':'); const port = portStr ? parseInt(portStr, 10) : useSSL ? 443 : 9000; if (!hostname) { throw new Error(`Invalid MinIO endpoint: ${endpoint}`); } this.client = new MinioClient({ endPoint: hostname, port, useSSL, accessKey, secretKey, }); this.bucketName = bucketName; // CDN base URL without bucket name (e.g., https://cdn.banatie.app) this.cdnBaseUrl = cdnBaseUrl || process.env['CDN_BASE_URL'] || `${useSSL ? 'https' : 'http'}://${endpoint}/${bucketName}`; } /** * Get file path in storage * Format: {orgSlug}/{projectSlug}/img/{imageId} */ private getFilePath(orgSlug: string, projectSlug: string, imageId: string): string { return `${orgSlug}/${projectSlug}/img/${imageId}`; } /** * Extract file extension from original filename */ private extractExtension(filename: string): string | undefined { if (!filename) return undefined; const lastDotIndex = filename.lastIndexOf('.'); if (lastDotIndex <= 0) return undefined; return filename.substring(lastDotIndex + 1).toLowerCase(); } /** * Validate storage path components */ private validatePath(orgSlug: string, projectSlug: string, imageId: string): void { // Validate orgSlug if (!orgSlug || !/^[a-zA-Z0-9_-]+$/.test(orgSlug) || orgSlug.length > 50) { throw new Error( 'Invalid organization slug: must be alphanumeric with dashes/underscores, max 50 chars', ); } // Validate projectSlug if (!projectSlug || !/^[a-zA-Z0-9_-]+$/.test(projectSlug) || projectSlug.length > 50) { throw new Error( 'Invalid project slug: must be alphanumeric with dashes/underscores, max 50 chars', ); } // Validate imageId (UUID format) if (!imageId || imageId.length === 0 || imageId.length > 50) { throw new Error('Invalid imageId: must be 1-50 characters'); } // Check for path traversal and dangerous patterns if (imageId.includes('..') || imageId.includes('/') || imageId.includes('\\')) { throw new Error('Invalid characters in imageId: path traversal not allowed'); } // Prevent null bytes and control characters if (/[\x00-\x1f]/.test(imageId)) { throw new Error('Invalid imageId: control characters not allowed'); } } async createBucket(): Promise { const exists = await this.client.bucketExists(this.bucketName); if (!exists) { await this.client.makeBucket(this.bucketName, 'us-east-1'); console.log(`Created bucket: ${this.bucketName}`); } // Bucket should be public for CDN access (configured via mc anonymous set download) console.log(`Bucket ${this.bucketName} ready for CDN access`); } async bucketExists(): Promise { return await this.client.bucketExists(this.bucketName); } async uploadFile( orgSlug: string, projectSlug: string, imageId: string, buffer: Buffer, contentType: string, originalFilename?: string, ): Promise { // Validate inputs first this.validatePath(orgSlug, projectSlug, imageId); if (!buffer || buffer.length === 0) { throw new Error('Buffer cannot be empty'); } if (!contentType || contentType.trim().length === 0) { throw new Error('Content type is required'); } // Ensure bucket exists await this.createBucket(); // Get file path: {orgSlug}/{projectSlug}/img/{imageId} const filePath = this.getFilePath(orgSlug, projectSlug, imageId); // Extract file extension from original filename const fileExtension = originalFilename ? this.extractExtension(originalFilename) : undefined; // Encode original filename to Base64 to safely store non-ASCII characters in metadata const originalNameEncoded = originalFilename ? Buffer.from(originalFilename, 'utf-8').toString('base64') : undefined; const metadata: Record = { 'Content-Type': contentType, 'X-Amz-Meta-Project': projectSlug, 'X-Amz-Meta-Organization': orgSlug, 'X-Amz-Meta-Upload-Time': new Date().toISOString(), }; if (originalNameEncoded) { metadata['X-Amz-Meta-Original-Name'] = originalNameEncoded; metadata['X-Amz-Meta-Original-Name-Encoding'] = 'base64'; } if (fileExtension) { metadata['X-Amz-Meta-File-Extension'] = fileExtension; } console.log(`[MinIO] Uploading file to: ${this.bucketName}/${filePath}`); await this.client.putObject( this.bucketName, filePath, buffer, buffer.length, metadata, ); const url = this.getPublicUrl(orgSlug, projectSlug, imageId); console.log(`[MinIO] CDN URL: ${url}`); return { success: true, filename: imageId, path: filePath, url, size: buffer.length, contentType, ...(originalFilename && { originalFilename }), ...(fileExtension && { fileExtension }), }; } async downloadFile( orgSlug: string, projectSlug: string, imageId: string, ): Promise { this.validatePath(orgSlug, projectSlug, imageId); const filePath = this.getFilePath(orgSlug, projectSlug, imageId); const stream = await this.client.getObject(this.bucketName, filePath); return new Promise((resolve, reject) => { const chunks: Buffer[] = []; stream.on('data', (chunk) => chunks.push(chunk)); stream.on('end', () => resolve(Buffer.concat(chunks))); stream.on('error', reject); }); } async streamFile( orgSlug: string, projectSlug: string, imageId: string, ): Promise { this.validatePath(orgSlug, projectSlug, imageId); const filePath = this.getFilePath(orgSlug, projectSlug, imageId); // Return the stream directly without buffering - memory efficient! return await this.client.getObject(this.bucketName, filePath); } async deleteFile( orgSlug: string, projectSlug: string, imageId: string, ): Promise { this.validatePath(orgSlug, projectSlug, imageId); const filePath = this.getFilePath(orgSlug, projectSlug, imageId); await this.client.removeObject(this.bucketName, filePath); } /** * Get public CDN URL for file access * Returns: https://cdn.banatie.app/{orgSlug}/{projectSlug}/img/{imageId} */ getPublicUrl(orgSlug: string, projectSlug: string, imageId: string): string { this.validatePath(orgSlug, projectSlug, imageId); const filePath = this.getFilePath(orgSlug, projectSlug, imageId); return `${this.cdnBaseUrl}/${filePath}`; } async getPresignedUploadUrl( orgSlug: string, projectSlug: string, imageId: string, expirySeconds: number, contentType: string, ): Promise { this.validatePath(orgSlug, projectSlug, imageId); if (!contentType || contentType.trim().length === 0) { throw new Error('Content type is required for presigned upload URL'); } const filePath = this.getFilePath(orgSlug, projectSlug, imageId); return await this.client.presignedPutObject(this.bucketName, filePath, expirySeconds); } async getPresignedDownloadUrl( orgSlug: string, projectSlug: string, imageId: string, expirySeconds: number = 86400, // 24 hours default ): Promise { this.validatePath(orgSlug, projectSlug, imageId); const filePath = this.getFilePath(orgSlug, projectSlug, imageId); const presignedUrl = await this.client.presignedGetObject( this.bucketName, filePath, expirySeconds, ); // Replace internal Docker hostname with CDN URL if configured if (this.cdnBaseUrl) { // Access protected properties via type assertion for URL replacement const client = this.client as unknown as { host: string; port: number; protocol: string }; const clientEndpoint = client.host + (client.port ? `:${client.port}` : ''); return presignedUrl.replace(`${client.protocol}//${clientEndpoint}/${this.bucketName}`, this.cdnBaseUrl); } return presignedUrl; } /** * List files in a project's img folder */ async listFiles( orgSlug: string, projectSlug: string, prefix?: string, ): Promise { this.validatePath(orgSlug, projectSlug, 'dummy'); const basePath = `${orgSlug}/${projectSlug}/img/`; const searchPrefix = prefix ? `${basePath}${prefix}` : basePath; const files: FileMetadata[] = []; return new Promise((resolve, reject) => { const stream = this.client.listObjects(this.bucketName, searchPrefix, true); stream.on('data', async (obj) => { if (!obj.name || !obj.size) return; try { const pathParts = obj.name.split('/'); const imageId = pathParts[pathParts.length - 1]; if (!imageId) return; // Get metadata to find content type (no extension in filename) const metadata = await this.client.statObject(this.bucketName, obj.name); files.push({ filename: imageId!, size: obj.size, contentType: metadata.metaData?.['content-type'] || 'application/octet-stream', lastModified: obj.lastModified || new Date(), etag: obj.etag || '', path: obj.name, }); } catch (error) { console.error('[MinIO listFiles] Error processing file:', obj.name, error); } }); stream.on('end', () => { resolve(files); }); stream.on('error', (error) => { console.error('[MinIO listFiles] Stream error:', error); reject(error); }); }); } /** * Parse storage key to extract components * Format: {orgSlug}/{projectSlug}/img/{imageId} */ parseKey(key: string): { orgSlug: string; projectSlug: string; imageId: string; } | null { try { // Match: orgSlug/projectSlug/img/imageId const match = key.match(/^([^/]+)\/([^/]+)\/img\/([^/]+)$/); if (!match) { return null; } const [, orgSlug, projectSlug, imageId] = match; if (!orgSlug || !projectSlug || !imageId) { return null; } return { orgSlug, projectSlug, imageId, }; } catch { return null; } } async fileExists( orgSlug: string, projectSlug: string, imageId: string, ): Promise { try { this.validatePath(orgSlug, projectSlug, imageId); const filePath = this.getFilePath(orgSlug, projectSlug, imageId); await this.client.statObject(this.bucketName, filePath); return true; } catch (error) { return false; } } }