UNPKG

@clicktime/mcp-server

Version:

ClickTime MCP Tech Demo for AI agents to interact with ClickTime API

154 lines (153 loc) 5.75 kB
// src/expense/receipt-processor.ts import * as fs from 'fs/promises'; import { extname } from 'path'; import { SUPPORTED_RECEIPT_MIME_TYPES, } from './expense-types.js'; export class ReceiptProcessor { // ClickTime’s documented limit is 2 MB, so match it here static MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB /** * The very small set of file extensions we’ll accept. This is a * deliberately cheap guard to stop users from accidentally selecting * something like ~/.ssh/id_rsa when the LLM asks for a path. */ static ALLOWED_EXTS = new Set([ '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.pdf', ]); static MIME_TYPE_MAP = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.bmp': 'image/bmp', '.pdf': 'application/pdf', }; /** * Lightweight defence‑in‑depth before we read any bytes into memory. * 1. Extension must be one of ALLOWED_EXTS. * 2. Reject symbolic links to avoid sneaky inode swaps. * 3. Enforce ClickTime’s 2 MB size cap via lstat (cheap). */ static async validateReceiptPath(filePath) { const ext = extname(filePath).toLowerCase(); if (!this.ALLOWED_EXTS.has(ext)) { throw new Error(`File must be one of: ${[...this.ALLOWED_EXTS].join(', ')}`); } const stats = await fs.lstat(filePath); if (stats.isSymbolicLink()) { throw new Error('Refusing symbolic‑link receipt file'); } if (stats.size > this.MAX_FILE_SIZE) { throw new Error(`File is too large: ${stats.size} bytes (limit is ${this.MAX_FILE_SIZE} bytes)`); } } /** * Process a receipt from a local file path. */ static async processReceiptFromFile(filePath) { try { // 1️⃣ Fast pre‑flight guards await this.validateReceiptPath(filePath); // 2️⃣ Resolve MIME type from extension (guaranteed to exist after validation) const extension = extname(filePath).toLowerCase(); const mimeType = this.MIME_TYPE_MAP[extension]; // 3️⃣ Read & encode const fileBuffer = await fs.readFile(filePath); const base64Data = fileBuffer.toString('base64'); return { receipt: base64Data, receiptFileType: mimeType, }; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to process receipt file: ${error.message}`); } throw new Error('Failed to process receipt file: Unknown error'); } } /** * Process a receipt from base64 data. */ static async processReceiptFromBase64(base64Data, mimeType) { // Validate MIME type if (!this.isValidMimeType(mimeType)) { throw new Error(`Unsupported MIME type: ${mimeType}. Supported: ${SUPPORTED_RECEIPT_MIME_TYPES.join(', ')}`); } const clean = this.cleanBase64(base64Data); // Validate base64 format if (!this.isValidBase64(clean)) { throw new Error('Invalid base64 data provided'); } // Estimate file size from base64 (rough approximation) const estimatedSize = (clean.length * 3) / 4; if (estimatedSize > this.MAX_FILE_SIZE) { throw new Error(`Estimated file size exceeds maximum allowed size of ${this.MAX_FILE_SIZE} bytes`); } return { receipt: clean, receiptFileType: mimeType, }; } /** Validate if MIME type is supported */ static isValidMimeType(mimeType) { return SUPPORTED_RECEIPT_MIME_TYPES.includes(mimeType); } /** Node‑safe base64 check */ static isValidBase64(str) { if (!str.trim()) return false; // <- reject ‘’ / whitespace try { const clean = str.replace(/^data:[^;]+;base64,/, ''); const buf = Buffer.from(clean, 'base64'); return buf.toString('base64') === clean; } catch { return false; } } /** Remove data‑URL prefix if present */ static cleanBase64(base64Data) { return base64Data.replace(/^data:[^;]+;base64,/, ''); } /** Map MIME → file‑extension */ static getFileExtensionForMimeType(mimeType) { const extensions = { 'image/jpeg': '.jpg', 'image/x-jpeg': '.jpg', 'image/pjpeg': '.jpg', 'image/png': '.png', 'image/x-png': '.png', 'image/gif': '.gif', 'image/x-gif': '.gif', 'image/bmp': '.bmp', 'image/x-bmp': '.bmp', 'image/pdf': '.pdf', 'application/pdf': '.pdf', }; return extensions[mimeType] || '.unknown'; } /** Human‑readable file‑size */ static formatFileSize(bytes) { const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${Math.round(size * 100) / 100} ${units[unitIndex]}`; } /** * Return a friendly blurb to show users explaining ClickTime’s * receipt‑upload rules (size + formats). */ static getClickTimeFileInfo() { const formats = SUPPORTED_RECEIPT_MIME_TYPES.join(', '); return `⚠️ ClickTime Receipt Requirements\n• Supported formats: ${formats}\n• Maximum file size: ${this.formatFileSize(this.MAX_FILE_SIZE)}`; } }