@clicktime/mcp-server
Version:
ClickTime MCP Tech Demo for AI agents to interact with ClickTime API
154 lines (153 loc) • 5.75 kB
JavaScript
// 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)}`;
}
}