banatie-service/apps/api-service/src/services/MinioStorageService.ts

375 lines
11 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 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<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}`);
}
// 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<boolean> {
return await this.client.bucketExists(this.bucketName);
}
async uploadFile(
orgSlug: string,
projectSlug: string,
imageId: string,
buffer: Buffer,
contentType: string,
originalFilename?: string,
): Promise<UploadResult> {
// 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<string, string> = {
'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<Buffer> {
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<import('stream').Readable> {
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<void> {
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<string> {
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<string> {
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<FileMetadata[]> {
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<boolean> {
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;
}
}
}