fix: isssues

This commit is contained in:
Oleg Proskurin 2025-09-29 00:02:07 +07:00
parent 0b6bb5662c
commit 3c922c861b
4 changed files with 308 additions and 41 deletions

View File

@ -24,14 +24,21 @@ imagesRouter.get(
const storageService = StorageFactory.getInstance();
try {
// Stream the file directly through our API (more reliable than presigned URL redirects)
const fileBuffer = await storageService.downloadFile(
// Check if file exists first (fast check)
const exists = await storageService.fileExists(
orgId,
projectId,
category as 'uploads' | 'generated' | 'references',
filename
);
if (!exists) {
return res.status(404).json({
success: false,
message: 'File not found'
});
}
// Determine content type from filename
const ext = filename.toLowerCase().split('.').pop();
const contentType = {
@ -43,11 +50,38 @@ imagesRouter.get(
'svg': 'image/svg+xml'
}[ext || ''] || 'application/octet-stream';
// Set headers for optimal caching and performance
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=86400'); // 24 hours
res.setHeader('Content-Length', fileBuffer.length);
res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); // 24 hours + immutable
res.setHeader('ETag', `"${orgId}-${projectId}-${filename}"`); // Simple ETag
return res.send(fileBuffer);
// Handle conditional requests (304 Not Modified)
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === `"${orgId}-${projectId}-${filename}"`) {
return res.status(304).end(); // Not Modified
}
// Stream the file directly through our API (memory efficient)
const fileStream = await storageService.streamFile(
orgId,
projectId,
category as 'uploads' | 'generated' | 'references',
filename
);
// Handle stream errors
fileStream.on('error', (streamError) => {
console.error('Stream error:', streamError);
if (!res.headersSent) {
res.status(500).json({
success: false,
message: 'Error streaming file'
});
}
});
// Stream the file without loading into memory
fileStream.pipe(res);
} catch (error) {
console.error('Failed to stream file:', error);

View File

@ -48,18 +48,63 @@ export class MinioStorageService implements StorageService {
}
private generateUniqueFilename(originalFilename: string): string {
// Sanitize filename first
const sanitized = this.sanitizeFilename(originalFilename);
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
const ext = originalFilename.includes('.')
? originalFilename.substring(originalFilename.lastIndexOf('.'))
const ext = sanitized.includes('.')
? sanitized.substring(sanitized.lastIndexOf('.'))
: '';
const name = originalFilename.includes('.')
? originalFilename.substring(0, originalFilename.lastIndexOf('.'))
: originalFilename;
const name = sanitized.includes('.')
? sanitized.substring(0, sanitized.lastIndexOf('.'))
: sanitized;
return `${name}-${timestamp}-${random}${ext}`;
}
private sanitizeFilename(filename: string): string {
// Remove dangerous characters and path traversal attempts
return filename
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '') // Remove dangerous chars
.replace(/\.\./g, '') // Remove path traversal
.replace(/^\.+/, '') // Remove leading dots
.trim()
.substring(0, 255); // Limit length
}
private validateFilePath(orgId: string, projectId: string, category: string, filename: string): void {
// Validate orgId
if (!orgId || !/^[a-zA-Z0-9_-]+$/.test(orgId) || orgId.length > 50) {
throw new Error('Invalid organization ID: must be alphanumeric with dashes/underscores, max 50 chars');
}
// Validate projectId
if (!projectId || !/^[a-zA-Z0-9_-]+$/.test(projectId) || projectId.length > 50) {
throw new Error('Invalid project ID: must be alphanumeric with dashes/underscores, max 50 chars');
}
// Validate category
if (!['uploads', 'generated', 'references'].includes(category)) {
throw new Error('Invalid category: must be uploads, generated, or references');
}
// Validate filename
if (!filename || filename.length === 0 || filename.length > 255) {
throw new Error('Invalid filename: must be 1-255 characters');
}
// Check for path traversal and dangerous patterns
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
throw new Error('Invalid characters in filename: path traversal not allowed');
}
// Prevent null bytes and control characters
if (/[\x00-\x1f]/.test(filename)) {
throw new Error('Invalid filename: control characters not allowed');
}
}
async createBucket(): Promise<void> {
const exists = await this.client.bucketExists(this.bucketName);
if (!exists) {
@ -83,6 +128,17 @@ export class MinioStorageService implements StorageService {
buffer: Buffer,
contentType: string
): Promise<UploadResult> {
// Validate inputs first
this.validateFilePath(orgId, projectId, category, filename);
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();
@ -129,6 +185,7 @@ export class MinioStorageService implements StorageService {
category: 'uploads' | 'generated' | 'references',
filename: string
): Promise<Buffer> {
this.validateFilePath(orgId, projectId, category, filename);
const filePath = this.getFilePath(orgId, projectId, category, filename);
const stream = await this.client.getObject(this.bucketName, filePath);
@ -141,12 +198,26 @@ export class MinioStorageService implements StorageService {
});
}
async streamFile(
orgId: string,
projectId: string,
category: 'uploads' | 'generated' | 'references',
filename: string
): Promise<import('stream').Readable> {
this.validateFilePath(orgId, projectId, category, filename);
const filePath = this.getFilePath(orgId, projectId, category, filename);
// Return the stream directly without buffering - memory efficient!
return await this.client.getObject(this.bucketName, filePath);
}
async deleteFile(
orgId: string,
projectId: string,
category: 'uploads' | 'generated' | 'references',
filename: string
): Promise<void> {
this.validateFilePath(orgId, projectId, category, filename);
const filePath = this.getFilePath(orgId, projectId, category, filename);
await this.client.removeObject(this.bucketName, filePath);
}
@ -157,6 +228,7 @@ export class MinioStorageService implements StorageService {
category: 'uploads' | 'generated' | 'references',
filename: string
): string {
this.validateFilePath(orgId, projectId, category, filename);
// 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}`;
@ -167,8 +239,15 @@ export class MinioStorageService implements StorageService {
projectId: string,
category: 'uploads' | 'generated' | 'references',
filename: string,
expirySeconds: number = 3600
expirySeconds: number,
contentType: string
): Promise<string> {
this.validateFilePath(orgId, projectId, category, filename);
if (!contentType || contentType.trim().length === 0) {
throw new Error('Content type is required for presigned upload URL');
}
const filePath = this.getFilePath(orgId, projectId, category, filename);
return await this.client.presignedPutObject(this.bucketName, filePath, expirySeconds);
}
@ -180,6 +259,7 @@ export class MinioStorageService implements StorageService {
filename: string,
expirySeconds: number = 86400 // 24 hours default
): Promise<string> {
this.validateFilePath(orgId, projectId, category, filename);
const filePath = this.getFilePath(orgId, projectId, category, filename);
const presignedUrl = await this.client.presignedGetObject(this.bucketName, filePath, expirySeconds);
@ -271,4 +351,67 @@ export class MinioStorageService implements StorageService {
return null;
}
}
// MISSING METHODS FROM INTERFACE
async fileExists(
orgId: string,
projectId: string,
category: 'uploads' | 'generated' | 'references',
filename: string
): Promise<boolean> {
try {
this.validateFilePath(orgId, projectId, category, filename);
const filePath = this.getFilePath(orgId, projectId, category, filename);
await this.client.statObject(this.bucketName, filePath);
return true;
} catch (error) {
return false;
}
}
async listFiles(
orgId: string,
projectId: string,
category: 'uploads' | 'generated' | 'references',
prefix?: string
): Promise<FileMetadata[]> {
this.validateFilePath(orgId, projectId, category, 'dummy.txt'); // Validate path components
const basePath = `${orgId}/${projectId}/${category}/`;
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 filename = pathParts[pathParts.length - 1];
if (!filename) return;
const metadata = await this.client.statObject(this.bucketName, obj.name);
files.push({
filename,
size: obj.size,
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream',
lastModified: obj.lastModified || new Date(),
etag: metadata.etag,
path: obj.name
});
} catch (error) {
console.error(`Error processing file ${obj.name}:`, error);
}
});
stream.on('end', () => resolve(files));
stream.on('error', reject);
});
}
}

View File

@ -3,17 +3,86 @@ import { MinioStorageService } from './MinioStorageService';
export class StorageFactory {
private static instance: StorageService | null = null;
private static initializationPromise: Promise<StorageService> | null = null;
static getInstance(): StorageService {
static async getInstance(): Promise<StorageService> {
if (this.instance) {
return this.instance;
}
if (this.initializationPromise) {
return await this.initializationPromise;
}
this.initializationPromise = this.createStorageServiceWithRetry();
try {
this.instance = await this.initializationPromise;
return this.instance;
} catch (error) {
this.initializationPromise = null;
throw error;
}
}
// Synchronous version for backward compatibility (with graceful degradation)
static getInstanceSync(): StorageService {
if (!this.instance) {
try {
this.instance = this.createStorageService();
} catch (error) {
console.error('Failed to create storage service:', error);
throw new Error('Storage service unavailable. Please check MinIO configuration.');
}
}
return this.instance;
}
private static async createStorageServiceWithRetry(): Promise<StorageService> {
const maxRetries = 3;
const baseDelay = 1000; // 1 second
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`Attempting to create storage service (attempt ${attempt}/${maxRetries})`);
const service = this.createStorageService();
// Test the connection by checking if bucket exists
await service.bucketExists();
console.log('Storage service created successfully');
return service;
} catch (error) {
console.error(`Storage service creation attempt ${attempt} failed:`, error);
if (attempt === maxRetries) {
throw new Error(
`Failed to initialize storage service after ${maxRetries} attempts. ` +
`Last error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
// Exponential backoff
const delay = baseDelay * Math.pow(2, attempt - 1);
console.log(`Waiting ${delay}ms before retry...`);
await this.sleep(delay);
}
}
throw new Error('Unexpected error in storage service creation');
}
private static sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private static createStorageService(): StorageService {
const storageType = process.env['STORAGE_TYPE'] || 'minio';
try {
switch (storageType.toLowerCase()) {
case 'minio': {
const endpoint = process.env['MINIO_ENDPOINT'];
@ -48,10 +117,15 @@ export class StorageFactory {
default:
throw new Error(`Unsupported storage type: ${storageType}`);
}
} catch (error) {
console.error('Error creating storage service:', error);
throw error;
}
}
// Reset instance for testing
static resetInstance(): void {
this.instance = null;
this.initializationPromise = null;
}
}

View File

@ -1,3 +1,5 @@
import { Readable } from 'stream';
export interface FileMetadata {
filename: string;
size: number;
@ -60,6 +62,20 @@ export interface StorageService {
filename: string
): Promise<Buffer>;
/**
* Stream a file from storage (memory efficient)
* @param orgId Organization ID
* @param projectId Project ID
* @param category File category
* @param filename Filename to stream
*/
streamFile(
orgId: string,
projectId: string,
category: 'uploads' | 'generated' | 'references',
filename: string
): Promise<Readable>;
/**
* Generate a presigned URL for downloading a file
* @param orgId Organization ID