166 lines
4.5 KiB
TypeScript
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);
|
|
} |