Files
gridpilot.gg/adapters/media/ports/FileSystemMediaStorageAdapter.ts
2025-12-31 15:39:28 +01:00

166 lines
4.5 KiB
TypeScript

/**
* 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<UploadResult> {
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<void> {
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<Buffer | null> {
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<string, string> = {
'.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);
}