261 lines
7.7 KiB
TypeScript
261 lines
7.7 KiB
TypeScript
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<void> {
|
|
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<boolean> {
|
|
return await this.client.bucketExists(this.bucketName);
|
|
}
|
|
|
|
async uploadFile(
|
|
orgId: string,
|
|
projectId: string,
|
|
category: 'uploads' | 'generated' | 'references',
|
|
filename: string,
|
|
buffer: Buffer,
|
|
contentType: string
|
|
): Promise<UploadResult> {
|
|
// 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<Buffer> {
|
|
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<void> {
|
|
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<string> {
|
|
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<string> {
|
|
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<FileMetadata[]> {
|
|
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;
|
|
}
|
|
}
|
|
} |