Skip to main content
Glama
file-output-manager.ts13.9 kB
/** * File Output Manager for MCP Color Server * Handles file creation, naming, cleanup, and security validation */ import { promises as fs, existsSync, statSync } from 'fs'; import { join, basename, extname, resolve, normalize } from 'path'; import { randomBytes } from 'crypto'; import * as fsExtra from 'fs-extra'; import { environmentConfig } from './environment-config'; import { logger } from './logger'; export interface FileMetadata { path: string; filename: string; size: number; createdAt: Date; type: 'html' | 'png' | 'svg' | 'css' | 'json'; description?: string | undefined; } export interface DirectoryStats { totalFiles: number; totalSize: number; oldestFile?: Date; newestFile?: Date; filesByType: Record<string, number>; } export interface CleanupResult { filesRemoved: number; bytesFreed: number; errors: string[]; } export class FileOutputManager { private static instance: FileOutputManager; private initialized = false; private cleanupInterval: ReturnType<typeof setInterval> | null = null; private constructor() { // Private constructor for singleton } public static getInstance(): FileOutputManager { if (!FileOutputManager.instance) { FileOutputManager.instance = new FileOutputManager(); } return FileOutputManager.instance; } public async initialize(): Promise<void> { if (this.initialized) { return; } try { const config = environmentConfig.getConfig(); // Validate configuration const validation = environmentConfig.validateConfiguration(); if (!validation.valid) { throw new Error( `Invalid configuration: ${validation.errors.join(', ')}` ); } // Ensure visualizations directory exists await this.ensureDirectoryExists(config.visualizationsDir); // Set up automatic cleanup if enabled and not in test environment const isTestEnvironment = process.env['NODE_ENV'] === 'test' || process.env['JEST_WORKER_ID'] !== undefined || process.env['CI'] === 'true' || typeof jest !== 'undefined' || (typeof global !== 'undefined' && 'jest' in global); if (config.enableCleanup && !isTestEnvironment) { this.setupAutomaticCleanup(); } this.initialized = true; logger.info('FileOutputManager initialized successfully', { visualizationsDir: config.visualizationsDir, cleanupEnabled: config.enableCleanup, }); } catch (error) { logger.error('Failed to initialize FileOutputManager', { error: error as Error, }); throw error; } } private async ensureDirectoryExists(dirPath: string): Promise<void> { try { await fsExtra.ensureDir(dirPath); // Test write permissions const testFile = join(dirPath, '.write-test'); await fs.writeFile(testFile, 'test'); await fs.unlink(testFile); logger.debug(`Directory ensured and writable: ${dirPath}`); } catch (error) { logger.error(`Failed to ensure directory: ${dirPath}`, { error: error as Error, }); throw new Error(`Cannot create or write to directory: ${dirPath}`); } } public async saveFile( content: string | Buffer, type: 'html' | 'png' | 'svg' | 'css' | 'json', options: { description?: string; customName?: string; subdirectory?: string; } = {} ): Promise<FileMetadata> { if (!this.initialized) { await this.initialize(); } try { const config = environmentConfig.getConfig(); const filename = this.generateFileName(type, options.customName); let targetDir = config.visualizationsDir; if (options.subdirectory) { // Validate subdirectory for security const sanitizedSubdir = this.sanitizePathComponent( options.subdirectory ); targetDir = join(targetDir, sanitizedSubdir); await this.ensureDirectoryExists(targetDir); } const filePath = join(targetDir, filename); // Security validation this.validateFilePath(filePath, config.visualizationsDir); // Write file if (Buffer.isBuffer(content)) { await fs.writeFile(filePath, content); } else { await fs.writeFile(filePath, content, 'utf8'); } // Get file stats const stats = await fs.stat(filePath); const metadata: FileMetadata = { path: filePath, filename, size: stats.size, createdAt: stats.birthtime, type, description: options.description, }; logger.info('File saved successfully', { path: filePath, size: stats.size, fileType: type, }); return metadata; } catch (error) { logger.error(`Failed to save file of type ${type}`, { error: error as Error, }); throw error; } } private generateFileName(type: string, customName?: string): string { const config = environmentConfig.getConfig(); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const uniqueId = randomBytes(4).toString('hex'); let baseName: string; if (customName) { // Sanitize custom name baseName = this.sanitizePathComponent(customName); } else { baseName = `${config.fileNamePrefix}-${type}-${timestamp}-${uniqueId}`; } // Ensure proper extension const extension = this.getFileExtension(type); if (!baseName.endsWith(extension)) { baseName += extension; } return baseName; } private getFileExtension(type: string): string { const extensions: Record<string, string> = { html: '.html', png: '.png', svg: '.svg', css: '.css', json: '.json', }; return extensions[type] || '.txt'; } private sanitizePathComponent(component: string): string { // Remove dangerous characters and normalize return ( component .replace(/[<>:"/\\|?*]/g, '') // eslint-disable-next-line no-control-regex .replace(/[\u0000-\u001F]/g, '') // Remove control characters .replace(/^\.+/, '') .replace(/\.+$/, '') .substring(0, 100) // Limit length .trim() ); } private validateFilePath(filePath: string, baseDir: string): void { const resolvedPath = resolve(filePath); const resolvedBaseDir = resolve(baseDir); // Ensure the file is within the base directory (prevent directory traversal) if (!resolvedPath.startsWith(resolvedBaseDir)) { throw new Error('Invalid file path: outside of allowed directory'); } // Check for suspicious patterns in the relative path only // This prevents false positives from legitimate system paths const relativePath = filePath.replace(baseDir, ''); const normalizedRelativePath = normalize(relativePath); // Only check for directory traversal patterns in the relative portion if (normalizedRelativePath.includes('..')) { throw new Error( 'Invalid file path: contains directory traversal patterns' ); } // Check for other suspicious patterns that could indicate path injection if (relativePath.includes('\0') || relativePath.includes('\x00')) { throw new Error('Invalid file path: contains null bytes'); } } public async getDirectoryStats(): Promise<DirectoryStats> { if (!this.initialized) { await this.initialize(); } const config = environmentConfig.getConfig(); const stats: DirectoryStats = { totalFiles: 0, totalSize: 0, filesByType: {}, }; try { const files = await this.getAllFiles(config.visualizationsDir); for (const file of files) { const fileStat = await fs.stat(file); const ext = extname(file).substring(1); stats.totalFiles++; stats.totalSize += fileStat.size; stats.filesByType[ext] = (stats.filesByType[ext] || 0) + 1; if (!stats.oldestFile || fileStat.birthtime < stats.oldestFile) { stats.oldestFile = fileStat.birthtime; } if (!stats.newestFile || fileStat.birthtime > stats.newestFile) { stats.newestFile = fileStat.birthtime; } } return stats; } catch (error) { logger.error('Failed to get directory stats', { error: error as Error }); throw error; } } private async getAllFiles(dir: string): Promise<string[]> { const files: string[] = []; try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { const subFiles = await this.getAllFiles(fullPath); files.push(...subFiles); } else if (entry.isFile()) { files.push(fullPath); } } } catch (error) { // Directory might not exist or be accessible logger.debug(`Cannot read directory: ${dir}`, { error: error as Error }); } return files; } public async cleanup(force = false): Promise<CleanupResult> { if (!this.initialized) { await this.initialize(); } const config = environmentConfig.getConfig(); const result: CleanupResult = { filesRemoved: 0, bytesFreed: 0, errors: [], }; if (!config.enableCleanup && !force) { logger.debug('Cleanup is disabled'); return result; } try { const files = await this.getAllFiles(config.visualizationsDir); const now = Date.now(); const maxAge = config.maxFileAge; for (const file of files) { try { const stats = await fs.stat(file); const age = now - stats.birthtime.getTime(); if (age > maxAge) { await fs.unlink(file); result.filesRemoved++; result.bytesFreed += stats.size; logger.debug(`Cleaned up old file: ${file}`, { age, size: stats.size, }); } } catch (error) { const errorMsg = `Failed to cleanup file ${file}: ${(error as Error).message}`; result.errors.push(errorMsg); logger.warn(errorMsg); } } // Check directory size and remove oldest files if needed const dirStats = await this.getDirectoryStats(); if (dirStats.totalSize > config.maxDirectorySize) { await this.cleanupBySize(config.maxDirectorySize, result); } logger.info('Cleanup completed', { filesRemoved: result.filesRemoved, bytesFreed: result.bytesFreed, errors: result.errors.length, }); } catch (error) { const errorMsg = `Cleanup failed: ${(error as Error).message}`; result.errors.push(errorMsg); logger.error(errorMsg); } return result; } private async cleanupBySize( maxSize: number, result: CleanupResult ): Promise<void> { const config = environmentConfig.getConfig(); const files = await this.getAllFiles(config.visualizationsDir); // Sort files by creation time (oldest first) const fileStats = await Promise.all( files.map(async file => ({ path: file, stats: await fs.stat(file), })) ); fileStats.sort( (a, b) => a.stats.birthtime.getTime() - b.stats.birthtime.getTime() ); let currentSize = fileStats.reduce((sum, file) => sum + file.stats.size, 0); for (const file of fileStats) { if (currentSize <= maxSize) { break; } try { await fs.unlink(file.path); currentSize -= file.stats.size; result.filesRemoved++; result.bytesFreed += file.stats.size; logger.debug(`Removed file for size limit: ${file.path}`, { size: file.stats.size, }); } catch (error) { const errorMsg = `Failed to remove file ${file.path}: ${(error as Error).message}`; result.errors.push(errorMsg); logger.warn(errorMsg); } } } private setupAutomaticCleanup(): void { // Run cleanup every hour this.cleanupInterval = setInterval( async () => { try { await this.cleanup(); } catch (error) { logger.error('Automatic cleanup failed', { error: error as Error }); } }, 60 * 60 * 1000 ); // 1 hour logger.info('Automatic cleanup scheduled every hour'); } public async deleteFile(filePath: string): Promise<void> { if (!this.initialized) { await this.initialize(); } const config = environmentConfig.getConfig(); // Security validation this.validateFilePath(filePath, config.visualizationsDir); try { await fs.unlink(filePath); logger.info(`File deleted: ${filePath}`); } catch (error) { logger.error(`Failed to delete file: ${filePath}`, { error: error as Error, }); throw error; } } public async getFileInfo(filePath: string): Promise<FileMetadata | null> { if (!existsSync(filePath)) { return null; } try { const stats = statSync(filePath); const ext = extname(filePath).substring(1) as FileMetadata['type']; return { path: filePath, filename: basename(filePath), size: stats.size, createdAt: stats.birthtime, type: ext, }; } catch (error) { logger.error(`Failed to get file info: ${filePath}`, { error: error as Error, }); return null; } } public destroy(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } this.initialized = false; logger.info('FileOutputManager destroyed'); } } // Export singleton instance export const fileOutputManager = FileOutputManager.getInstance();

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/keyurgolani/ColorMcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server