harden media
This commit is contained in:
166
adapters/media/ports/FileSystemMediaStorageAdapter.ts
Normal file
166
adapters/media/ports/FileSystemMediaStorageAdapter.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user