import { Client as MinioClient } from 'minio'; import { StorageService, FileMetadata, UploadResult } from './StorageService'; export class MinioStorageService implements StorageService { private client: MinioClient; private bucketName: string; private publicUrl: string; constructor( endpoint: string, accessKey: string, secretKey: string, useSSL: boolean = false, bucketName: string = 'banatie', publicUrl?: 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: port, useSSL, accessKey, secretKey }); this.bucketName = bucketName; this.publicUrl = publicUrl || `${useSSL ? 'https' : 'http'}://${endpoint}`; } private getFilePath( orgId: string, projectId: string, category: 'uploads' | 'generated' | 'references', filename: string ): string { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); return `${orgId}/${projectId}/${category}/${year}-${month}/${filename}`; } private generateUniqueFilename(originalFilename: string): string { const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 8); const ext = originalFilename.includes('.') ? originalFilename.substring(originalFilename.lastIndexOf('.')) : ''; const name = originalFilename.includes('.') ? originalFilename.substring(0, originalFilename.lastIndexOf('.')) : originalFilename; return `${name}-${timestamp}-${random}${ext}`; } 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}`); } // Note: With SNMD and presigned URLs, we don't need bucket policies console.log(`Bucket ${this.bucketName} ready for presigned URL access`); } async bucketExists(): Promise { return await this.client.bucketExists(this.bucketName); } async uploadFile( orgId: string, projectId: string, category: 'uploads' | 'generated' | 'references', filename: string, buffer: Buffer, contentType: string ): Promise { // Ensure bucket exists await this.createBucket(); // Generate unique filename to avoid conflicts const uniqueFilename = this.generateUniqueFilename(filename); const filePath = this.getFilePath(orgId, projectId, category, uniqueFilename); const metadata = { 'Content-Type': contentType, 'X-Amz-Meta-Original-Name': filename, 'X-Amz-Meta-Category': category, 'X-Amz-Meta-Project': projectId, 'X-Amz-Meta-Organization': orgId, 'X-Amz-Meta-Upload-Time': new Date().toISOString() }; console.log(`Uploading file to: ${this.bucketName}/${filePath}`); const result = await this.client.putObject( this.bucketName, filePath, buffer, buffer.length, metadata ); const url = this.getPublicUrl(orgId, projectId, category, uniqueFilename); console.log(`Generated API URL: ${url}`); return { success: true, filename: uniqueFilename, path: filePath, url, size: buffer.length, contentType }; } async downloadFile( orgId: string, projectId: string, category: 'uploads' | 'generated' | 'references', filename: string ): Promise { const filePath = this.getFilePath(orgId, projectId, category, filename); 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 deleteFile( orgId: string, projectId: string, category: 'uploads' | 'generated' | 'references', filename: string ): Promise { const filePath = this.getFilePath(orgId, projectId, category, filename); await this.client.removeObject(this.bucketName, filePath); } getPublicUrl( orgId: string, projectId: string, category: 'uploads' | 'generated' | 'references', filename: string ): string { // Production-ready: Return API URL for presigned URL access const apiBaseUrl = process.env['API_BASE_URL'] || 'http://localhost:3000'; return `${apiBaseUrl}/api/images/${orgId}/${projectId}/${category}/${filename}`; } async getPresignedUploadUrl( orgId: string, projectId: string, category: 'uploads' | 'generated' | 'references', filename: string, expirySeconds: number = 3600 ): Promise { const filePath = this.getFilePath(orgId, projectId, category, filename); return await this.client.presignedPutObject(this.bucketName, filePath, expirySeconds); } async getPresignedDownloadUrl( orgId: string, projectId: string, category: 'uploads' | 'generated' | 'references', filename: string, expirySeconds: number = 86400 // 24 hours default ): Promise { const filePath = this.getFilePath(orgId, projectId, category, filename); return await this.client.presignedGetObject(this.bucketName, filePath, expirySeconds); } async listProjectFiles( orgId: string, projectId: string, category?: 'uploads' | 'generated' | 'references' ): Promise { const prefix = category ? `${orgId}/${projectId}/${category}/` : `${orgId}/${projectId}/`; const files: FileMetadata[] = []; return new Promise((resolve, reject) => { const stream = this.client.listObjects(this.bucketName, prefix, true); stream.on('data', async (obj) => { try { if (!obj.name) return; const metadata = await this.client.statObject(this.bucketName, obj.name); const pathParts = obj.name.split('/'); const filename = pathParts[pathParts.length - 1]; const categoryFromPath = pathParts[2] as 'uploads' | 'generated' | 'references'; if (!filename || !categoryFromPath) { return; } files.push({ key: `${this.bucketName}/${obj.name}`, filename, contentType: metadata.metaData?.['content-type'] || 'application/octet-stream', size: obj.size || 0, url: this.getPublicUrl(orgId, projectId, categoryFromPath, filename), createdAt: obj.lastModified || new Date() }); } catch (error) { console.error(`Error processing file ${obj.name}:`, error); } }); stream.on('end', () => resolve(files)); stream.on('error', reject); }); } parseKey(key: string): { orgId: string; projectId: string; category: 'uploads' | 'generated' | 'references'; filename: string; } | null { try { // Key format: banatie/orgId/projectId/category/year-month/filename const match = key.match(/^banatie\/([^/]+)\/([^/]+)\/(uploads|generated|references)\/[^/]+\/(.+)$/); if (!match) { return null; } const [, orgId, projectId, category, filename] = match; if (!orgId || !projectId || !category || !filename) { return null; } return { orgId, projectId, category: category as 'uploads' | 'generated' | 'references', filename }; } catch { return null; } } }