UNPKG

@codai/memorai

Version:

Universal Database & Storage Service for CODAI Ecosystem - CBD Backend

397 lines 15.3 kB
/** * Storage Service - Production Implementation */ import { EventEmitter } from 'events'; import { promises as fs } from 'fs'; import path from 'path'; import { createHash } from 'crypto'; import sharp from 'sharp'; export class StorageService extends EventEmitter { constructor(config) { super(); this.config = config; this.isInitialized = false; this.storagePath = ''; this.storagePath = process.env.STORAGE_PATH || './storage'; } async initialize() { try { // Ensure storage directory exists await fs.mkdir(this.storagePath, { recursive: true }); // Create subdirectories const subdirs = ['uploads', 'temp', 'cache', 'backups']; for (const subdir of subdirs) { await fs.mkdir(path.join(this.storagePath, subdir), { recursive: true }); } this.isInitialized = true; this.emit('initialized'); console.log('📁 Storage Service initialized'); } catch (error) { console.error('Failed to initialize storage service:', error); this.emit('error', error); throw error; } } async shutdown() { if (this.isInitialized) { this.isInitialized = false; this.emit('shutdown'); console.log('📁 Storage Service shutdown'); } } async upload(file, filePath) { if (!this.isInitialized) { throw new Error('Storage service not initialized'); } try { const startTime = Date.now(); // Validate file await this.validateFile(file); // Generate unique filename if needed const { filename, fullPath } = await this.generateFilePath(filePath, file.filename); // Ensure directory exists await fs.mkdir(path.dirname(fullPath), { recursive: true }); let fileBuffer; // Convert file data to Buffer if (file.file instanceof Buffer) { fileBuffer = file.file; } else { // Handle ReadableStream const chunks = []; const stream = file.file; const reader = stream.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(Buffer.from(value)); } fileBuffer = Buffer.concat(chunks); } // Calculate file hash const hash = createHash('sha256').update(fileBuffer).digest('hex'); // Process image files (resize, optimize) if (this.isImageFile(file.mimeType)) { fileBuffer = await this.processImage(fileBuffer, file.mimeType); } // Write file to storage await fs.writeFile(fullPath, fileBuffer); const fileSize = fileBuffer.length; const uploadTime = Date.now() - startTime; // Generate public URL const url = await this.generateUrl(filePath); // Create storage file record const storageFile = { id: `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, filename, originalName: file.filename, mimeType: file.mimeType, size: file.size || fileSize, path: filePath, url, hash, userId: '', // Will be set by calling service tags: file.tags || [], isPublic: file.isPublic || false, downloadCount: 0, createdAt: new Date(), updatedAt: new Date(), version: 1, expiresAt: file.expiresAt, metadata: { uploadTime, originalSize: file.size, processedSize: fileSize, storageProvider: this.config.provider } }; this.emit('file:uploaded', { storageFile, uploadTime }); return storageFile; } catch (error) { console.error('File upload error:', error); this.emit('file:upload_error', { filePath, error }); throw error; } } async download(filePath) { if (!this.isInitialized) { throw new Error('Storage service not initialized'); } try { const fullPath = path.join(this.storagePath, filePath); // Check if file exists try { await fs.access(fullPath); } catch { throw new Error(`File not found: ${filePath}`); } const fileBuffer = await fs.readFile(fullPath); this.emit('file:downloaded', { filePath, size: fileBuffer.length }); return fileBuffer; } catch (error) { console.error('File download error:', error); this.emit('file:download_error', { filePath, error }); throw error; } } async delete(filePath) { if (!this.isInitialized) { throw new Error('Storage service not initialized'); } try { const fullPath = path.join(this.storagePath, filePath); // Check if file exists try { await fs.access(fullPath); } catch { // File doesn't exist, consider it deleted return true; } await fs.unlink(fullPath); this.emit('file:deleted', { filePath }); return true; } catch (error) { console.error('File deletion error:', error); this.emit('file:delete_error', { filePath, error }); return false; } } async getUrl(filePath, expiresIn) { // For local storage, return a local URL // In production, this would generate signed URLs for cloud providers const baseUrl = this.config.publicUrl || 'http://localhost:3000/storage'; return `${baseUrl}/${filePath}`; } async list(prefix) { if (!this.isInitialized) { throw new Error('Storage service not initialized'); } try { const searchPath = prefix ? path.join(this.storagePath, prefix) : this.storagePath; const files = []; const scan = async (dir, relativePath = '') => { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relativeFilePath = path.join(relativePath, entry.name); if (entry.isDirectory()) { await scan(fullPath, relativeFilePath); } else { const stats = await fs.stat(fullPath); const hash = await this.calculateFileHash(fullPath); files.push({ id: `file_${hash.substring(0, 16)}`, filename: entry.name, originalName: entry.name, mimeType: this.getMimeType(entry.name), size: stats.size, path: relativeFilePath, url: await this.getUrl(relativeFilePath), hash, userId: '', // Unknown for listed files tags: [], isPublic: false, downloadCount: 0, createdAt: stats.birthtime, updatedAt: stats.mtime, version: 1, metadata: { storageProvider: this.config.provider } }); } } }; await scan(searchPath); this.emit('files:listed', { count: files.length, prefix }); return files; } catch (error) { console.error('File listing error:', error); this.emit('files:list_error', { prefix, error }); return []; } } async copy(fromPath, toPath) { try { const fromFullPath = path.join(this.storagePath, fromPath); const toFullPath = path.join(this.storagePath, toPath); // Ensure destination directory exists await fs.mkdir(path.dirname(toFullPath), { recursive: true }); await fs.copyFile(fromFullPath, toFullPath); this.emit('file:copied', { fromPath, toPath }); return true; } catch (error) { console.error('File copy error:', error); this.emit('file:copy_error', { fromPath, toPath, error }); return false; } } async move(fromPath, toPath) { try { const fromFullPath = path.join(this.storagePath, fromPath); const toFullPath = path.join(this.storagePath, toPath); // Ensure destination directory exists await fs.mkdir(path.dirname(toFullPath), { recursive: true }); await fs.rename(fromFullPath, toFullPath); this.emit('file:moved', { fromPath, toPath }); return true; } catch (error) { console.error('File move error:', error); this.emit('file:move_error', { fromPath, toPath, error }); return false; } } async getHealth() { if (!this.isInitialized) { return { status: 'unhealthy', details: { initialized: false } }; } try { // Check if storage directory is accessible await fs.access(this.storagePath); // Get storage statistics const stats = await fs.stat(this.storagePath); return { status: 'healthy', details: { initialized: true, provider: this.config.provider, storagePath: this.storagePath, accessible: true, createdAt: stats.birthtime } }; } catch (error) { return { status: 'unhealthy', details: { initialized: false, error: error instanceof Error ? error.message : 'Unknown error' } }; } } // ==================== PRIVATE METHODS ==================== async validateFile(file) { // Check file size if (file.size && file.size > this.config.maxFileSize) { throw new Error(`File size exceeds limit: ${file.size} > ${this.config.maxFileSize}`); } // Check file type if (this.config.allowedTypes.length > 0) { const isAllowed = this.config.allowedTypes.some(type => file.mimeType.startsWith(type) || file.mimeType === type); if (!isAllowed) { throw new Error(`File type not allowed: ${file.mimeType}`); } } // Check filename if (!file.filename || file.filename.length === 0) { throw new Error('Filename is required'); } // Sanitize filename const sanitized = file.filename.replace(/[^a-zA-Z0-9.-]/g, '_'); if (sanitized !== file.filename) { file.filename = sanitized; } } async generateFilePath(basePath, filename) { const fullPath = path.join(this.storagePath, basePath, filename); // Check if file already exists try { await fs.access(fullPath); // Generate unique filename const ext = path.extname(filename); const base = path.basename(filename, ext); const timestamp = Date.now(); const newFilename = `${base}_${timestamp}${ext}`; return { filename: newFilename, fullPath: path.join(this.storagePath, basePath, newFilename) }; } catch { // File doesn't exist, use original filename return { filename, fullPath }; } } async processImage(buffer, mimeType) { if (!this.isImageFile(mimeType)) { return buffer; } try { // Process with Sharp for optimization let processed = sharp(buffer); // Auto-orient based on EXIF processed = processed.rotate(); // Resize if too large (max 2048px on longest side) processed = processed.resize(2048, 2048, { fit: 'inside', withoutEnlargement: true }); // Optimize based on format if (mimeType === 'image/jpeg') { processed = processed.jpeg({ quality: 85, progressive: true }); } else if (mimeType === 'image/png') { processed = processed.png({ compressionLevel: 6 }); } else if (mimeType === 'image/webp') { processed = processed.webp({ quality: 85 }); } return await processed.toBuffer(); } catch (error) { console.warn('Image processing failed, using original:', error); return buffer; } } isImageFile(mimeType) { return mimeType.startsWith('image/'); } async calculateFileHash(filePath) { const buffer = await fs.readFile(filePath); return createHash('sha256').update(buffer).digest('hex'); } getMimeType(filename) { const ext = path.extname(filename).toLowerCase(); const mimeTypes = { '.txt': 'text/plain', '.json': 'application/json', '.js': 'application/javascript', '.html': 'text/html', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.zip': 'application/zip', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.mov': 'video/quicktime' }; return mimeTypes[ext] || 'application/octet-stream'; } async generateUrl(filePath) { const baseUrl = this.config.publicUrl || `http://localhost:3000/api/storage`; return `${baseUrl}/${filePath}`; } } //# sourceMappingURL=StorageService.js.map