UNPKG

mcp-chrome-bridge

Version:

Chrome Native-Messaging host (Node)

370 lines 13.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.attachmentService = exports.AttachmentService = void 0; /** * Attachment Service for persisting and managing image attachments. * * Handles: * - Saving attachments to persistent storage (not temp files) * - Getting attachment statistics per project * - Cleaning up attachments by project or all * * Storage structure: * ~/.chrome-mcp-agent/attachments/{projectId}/{messageId}-{index}-{uuid}.{ext} */ const promises_1 = __importDefault(require("node:fs/promises")); const node_path_1 = __importDefault(require("node:path")); const node_crypto_1 = require("node:crypto"); const storage_1 = require("./storage"); // ============================================================ // Constants // ============================================================ const ATTACHMENTS_DIR_NAME = 'attachments'; /** Allowed MIME types for image attachments */ const ALLOWED_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); // ============================================================ // Helper Functions // ============================================================ /** * Convert MIME type to file extension. */ function mimeTypeToExt(mimeType) { switch (mimeType) { case 'image/png': return 'png'; case 'image/jpeg': return 'jpg'; case 'image/gif': return 'gif'; case 'image/webp': return 'webp'; default: return 'bin'; } } /** * Build a unique filename for an attachment. * Format: {messageId}-{index}-{uuid}.{ext} */ function buildAttachmentFilename(params) { const ext = mimeTypeToExt(params.mimeType); const uuid = (0, node_crypto_1.randomUUID)().slice(0, 8); return `${params.messageId}-${params.index}-${uuid}.${ext}`; } /** * Validate filename to prevent path traversal attacks. */ function isValidFilename(filename) { // Reject empty, path separators, parent directory references if (!filename || filename.includes('/') || filename.includes('\\')) { return false; } if (filename === '.' || filename === '..' || filename.startsWith('.')) { return false; } // Only allow alphanumeric, dash, underscore, dot return /^[a-zA-Z0-9_-]+\.[a-zA-Z0-9]+$/.test(filename); } /** * Validate projectId to prevent path traversal attacks. */ function isValidProjectId(projectId) { if (!projectId) return false; // UUID format or alphanumeric with dashes return /^[a-zA-Z0-9_-]+$/.test(projectId); } // ============================================================ // AttachmentService Class // ============================================================ class AttachmentService { /** * Get the root directory for all attachments. */ getAttachmentsRootDir() { return node_path_1.default.join((0, storage_1.getAgentDataDir)(), ATTACHMENTS_DIR_NAME); } /** * Get the directory for a specific project's attachments. */ getProjectAttachmentsDir(projectId) { if (!isValidProjectId(projectId)) { throw new Error(`Invalid projectId: ${projectId}`); } return node_path_1.default.join(this.getAttachmentsRootDir(), projectId); } /** * Get the absolute path for a specific attachment file. * Validates to prevent path traversal attacks. */ getAttachmentPath(projectId, filename) { if (!isValidProjectId(projectId)) { throw new Error(`Invalid projectId: ${projectId}`); } if (!isValidFilename(filename)) { throw new Error(`Invalid filename: ${filename}`); } const projectDir = this.getProjectAttachmentsDir(projectId); const filePath = node_path_1.default.join(projectDir, filename); // Double-check resolved path is within project directory (defense in depth) const resolved = node_path_1.default.resolve(filePath); const resolvedProjectDir = node_path_1.default.resolve(projectDir); if (!resolved.startsWith(resolvedProjectDir + node_path_1.default.sep)) { throw new Error('Path traversal attempt detected'); } return filePath; } /** * Save an attachment to persistent storage. * Creates directories if needed. */ async saveAttachment(input) { const { projectId, messageId, attachment, index } = input; // Validate input if (!isValidProjectId(projectId)) { throw new Error(`Invalid projectId: ${projectId}`); } if (attachment.type !== 'image') { throw new Error(`Unsupported attachment type: ${attachment.type}`); } if (!ALLOWED_MIME_TYPES.has(attachment.mimeType)) { throw new Error(`Unsupported MIME type: ${attachment.mimeType}`); } // Build filename and paths const filename = buildAttachmentFilename({ messageId, index, mimeType: attachment.mimeType, }); const projectDir = this.getProjectAttachmentsDir(projectId); const absolutePath = node_path_1.default.join(projectDir, filename); // Decode base64 and get size const buffer = Buffer.from(attachment.dataBase64, 'base64'); const sizeBytes = buffer.length; // Create directory and write file await promises_1.default.mkdir(projectDir, { recursive: true }); await promises_1.default.writeFile(absolutePath, buffer); // Build metadata const metadata = { version: 1, kind: 'image', projectId, messageId, index, filename, urlPath: `/agent/attachments/${projectId}/${filename}`, mimeType: attachment.mimeType, sizeBytes, originalName: attachment.name, createdAt: new Date().toISOString(), }; console.error(`[AttachmentService] Saved attachment: ${absolutePath} (${sizeBytes} bytes)`); return { absolutePath, filename, metadata, }; } /** * Get statistics for all attachments. */ async getAttachmentStats() { const rootDir = this.getAttachmentsRootDir(); const projects = []; let totalFiles = 0; let totalBytes = 0; try { // Check if root directory exists await promises_1.default.access(rootDir); // Read all project directories const entries = await promises_1.default.readdir(rootDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const projectId = entry.name; const dirPath = node_path_1.default.join(rootDir, projectId); try { const stats = await this.getProjectStats(projectId, dirPath); projects.push(stats); totalFiles += stats.fileCount; totalBytes += stats.totalBytes; } catch (error) { // Skip directories we can't read console.error(`[AttachmentService] Failed to stat project ${projectId}:`, error); } } } catch (_a) { // Root directory doesn't exist - return empty stats } return { rootDir, totalFiles, totalBytes, projects, }; } /** * Get statistics for a single project. */ async getProjectStats(projectId, dirPath) { let fileCount = 0; let totalBytes = 0; let lastModifiedAt; let latestMtime = 0; try { const files = await promises_1.default.readdir(dirPath); for (const file of files) { const filePath = node_path_1.default.join(dirPath, file); try { const stat = await promises_1.default.stat(filePath); if (stat.isFile()) { fileCount++; totalBytes += stat.size; if (stat.mtimeMs > latestMtime) { latestMtime = stat.mtimeMs; lastModifiedAt = stat.mtime.toISOString(); } } } catch (_a) { // Skip files we can't stat } } return { projectId, dirPath, exists: true, fileCount, totalBytes, lastModifiedAt, }; } catch (_b) { return { projectId, dirPath, exists: false, fileCount: 0, totalBytes: 0, }; } } /** * Cleanup attachments for specified projects or all projects. */ async cleanupAttachments(input) { const rootDir = this.getAttachmentsRootDir(); const results = []; let totalRemovedFiles = 0; let totalRemovedBytes = 0; // Determine which projects to clean let projectIds; if ((input === null || input === void 0 ? void 0 : input.projectIds) && input.projectIds.length > 0) { // Clean specific projects projectIds = input.projectIds; } else { // Clean all projects - enumerate from filesystem try { const entries = await promises_1.default.readdir(rootDir, { withFileTypes: true }); projectIds = entries.filter((e) => e.isDirectory()).map((e) => e.name); } catch (_a) { // Root doesn't exist - nothing to clean return { rootDir, removedFiles: 0, removedBytes: 0, results: [], }; } } // Clean each project for (const projectId of projectIds) { if (!isValidProjectId(projectId)) { console.error(`[AttachmentService] Skipping invalid projectId: ${projectId}`); continue; } const result = await this.cleanupProject(projectId); results.push(result); totalRemovedFiles += result.removedFiles; totalRemovedBytes += result.removedBytes; } return { rootDir, removedFiles: totalRemovedFiles, removedBytes: totalRemovedBytes, results, }; } /** * Cleanup attachments for a single project. */ async cleanupProject(projectId) { const dirPath = this.getProjectAttachmentsDir(projectId); try { // Get stats before deletion const stats = await this.getProjectStats(projectId, dirPath); if (!stats.exists) { return { projectId, dirPath, existed: false, removedFiles: 0, removedBytes: 0, }; } // Remove directory and all contents await promises_1.default.rm(dirPath, { recursive: true, force: true }); console.error(`[AttachmentService] Cleaned up ${stats.fileCount} files (${stats.totalBytes} bytes) for project ${projectId}`); return { projectId, dirPath, existed: true, removedFiles: stats.fileCount, removedBytes: stats.totalBytes, }; } catch (error) { console.error(`[AttachmentService] Failed to cleanup project ${projectId}:`, error); return { projectId, dirPath, existed: false, removedFiles: 0, removedBytes: 0, }; } } /** * Check if an attachment file exists. */ async attachmentExists(projectId, filename) { try { const filePath = this.getAttachmentPath(projectId, filename); await promises_1.default.access(filePath); return true; } catch (_a) { return false; } } /** * Read an attachment file. */ async readAttachment(projectId, filename) { const filePath = this.getAttachmentPath(projectId, filename); return promises_1.default.readFile(filePath); } } exports.AttachmentService = AttachmentService; // ============================================================ // Singleton Export // ============================================================ exports.attachmentService = new AttachmentService(); //# sourceMappingURL=attachment-service.js.map