/** * FileSystemMediaStorageAdapter * * Concrete adapter for storing media files on the filesystem. * Implements the MediaStoragePort interface. */ import { MediaStoragePort, UploadOptions, UploadResult } from '@core/media/application/ports/MediaStoragePort'; import * as fs from 'fs/promises'; import * as path from 'path'; /** * Configuration for FileSystemMediaStorageAdapter */ export interface FileSystemMediaStorageConfig { /** * Base directory for storing media files * @default '/data/media' */ baseDir?: string; } /** * FileSystemMediaStorageAdapter * * Stores media files in a local filesystem directory. * Uses deterministic storage keys based on mediaId. */ export class FileSystemMediaStorageAdapter implements MediaStoragePort { private readonly baseDir: string; constructor(config: FileSystemMediaStorageConfig = {}) { this.baseDir = config.baseDir || '/data/media'; } /** * Upload a media file to the filesystem * * @param buffer File buffer * @param options Upload options * @returns Upload result with storage key */ async uploadMedia(buffer: Buffer, options: UploadOptions): Promise { try { // Validate content type const allowedTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/gif']; if (!allowedTypes.includes(options.mimeType)) { return { success: false, errorMessage: `Content type ${options.mimeType} is not allowed`, }; } // Generate deterministic storage key const mediaId = this.generateMediaId(options.filename); const storageKey = `uploaded/${mediaId}`; const filePath = path.join(this.baseDir, storageKey); // Ensure directory exists await fs.mkdir(path.dirname(filePath), { recursive: true }); // Write file await fs.writeFile(filePath, buffer); return { success: true, filename: options.filename, url: storageKey, // Return storage key, not full URL }; } catch (error) { return { success: false, errorMessage: error instanceof Error ? error.message : String(error), }; } } /** * Delete a media file from the filesystem * * @param storageKey Storage key (e.g., 'uploaded/media-123') */ async deleteMedia(storageKey: string): Promise { try { const filePath = path.join(this.baseDir, storageKey); await fs.unlink(filePath); } catch (error) { // Ignore if file doesn't exist if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') { return; } throw error; } } /** * Get file bytes as Buffer * * @param storageKey Storage key * @returns Buffer or null if not found */ async getBytes(storageKey: string): Promise { try { const filePath = path.join(this.baseDir, storageKey); return await fs.readFile(filePath); } catch (error) { return null; } } /** * Get file metadata * * @param storageKey Storage key * @returns File metadata or null if not found */ async getMetadata(storageKey: string): Promise<{ size: number; contentType: string } | null> { try { const filePath = path.join(this.baseDir, storageKey); const stat = await fs.stat(filePath); // Determine content type from extension const ext = path.extname(filePath).toLowerCase(); const contentType = this.getContentTypeFromExtension(ext); return { size: stat.size, contentType, }; } catch (error) { return null; } } /** * Generate a deterministic media ID from filename */ private generateMediaId(filename: string): string { const timestamp = Date.now(); const cleanFilename = filename.replace(/[^a-zA-Z0-9.-]/g, '_'); return `media-${timestamp}-${cleanFilename}`; } /** * Get content type from file extension */ private getContentTypeFromExtension(ext: string): string { const map: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.gif': 'image/gif', }; return map[ext] || 'application/octet-stream'; } } /** * Factory function for creating FileSystemMediaStorageAdapter instances */ export function createFileSystemMediaStorage( config: FileSystemMediaStorageConfig = {} ): FileSystemMediaStorageAdapter { return new FileSystemMediaStorageAdapter(config); }