UNPKG

@codai/memorai

Version:

Universal Database & Storage Service for CODAI Ecosystem - CBD Backend

473 lines (381 loc) 14 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' import type { StorageFile, StorageUpload, MemoraiConfig } from '../types' export class StorageService extends EventEmitter { private isInitialized = false private storagePath = '' constructor(private config: MemoraiConfig['storage']) { super() this.storagePath = process.env.STORAGE_PATH || './storage' } async initialize(): Promise<void> { 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(): Promise<void> { if (this.isInitialized) { this.isInitialized = false this.emit('shutdown') console.log('📁 Storage Service shutdown') } } async upload(file: StorageUpload, filePath: string): Promise<StorageFile> { 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: Buffer // Convert file data to Buffer if (file.file instanceof Buffer) { fileBuffer = file.file } else { // Handle ReadableStream const chunks: Buffer[] = [] const stream = file.file as ReadableStream 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: 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: string): Promise<Buffer> { 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: string): Promise<boolean> { 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: string, expiresIn?: number): Promise<string> { // 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?: string): Promise<StorageFile[]> { if (!this.isInitialized) { throw new Error('Storage service not initialized') } try { const searchPath = prefix ? path.join(this.storagePath, prefix) : this.storagePath const files: StorageFile[] = [] const scan = async (dir: string, 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: string, toPath: string): Promise<boolean> { 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: string, toPath: string): Promise<boolean> { 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(): Promise<{ status: string; details?: any }> { 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 ==================== private async validateFile(file: StorageUpload): Promise<void> { // 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 } } private async generateFilePath(basePath: string, filename: string): Promise<{ filename: string; fullPath: string }> { 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 } } } private async processImage(buffer: Buffer, mimeType: string): Promise<Buffer> { 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 } } private isImageFile(mimeType: string): boolean { return mimeType.startsWith('image/') } private async calculateFileHash(filePath: string): Promise<string> { const buffer = await fs.readFile(filePath) return createHash('sha256').update(buffer).digest('hex') } private getMimeType(filename: string): string { const ext = path.extname(filename).toLowerCase() const mimeTypes: Record<string, string> = { '.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' } private async generateUrl(filePath: string): Promise<string> { const baseUrl = this.config.publicUrl || `http://localhost:3000/api/storage` return `${baseUrl}/${filePath}` } }