import crypto from 'crypto'; import fs from 'fs/promises'; import path from 'path'; export function getAttachmentsDir(): string { return process.env.ATTACHMENTS_DIR || path.join(process.cwd(), 'data', 'attachments'); } export async function saveAttachmentBuffer( buffer: Buffer, originalFilename: string, mimeType: string ): Promise<{ md5Hash: string; storagePath: string; sizeBytes: number; extractedText: string | null; }> { const md5Hash = crypto.createHash('md5').update(buffer).digest('hex'); const ext = path.extname(originalFilename) || guessExtensionFromMime(mimeType); const subDir = md5Hash.slice(0, 2); const baseDir = getAttachmentsDir(); const targetDir = path.join(baseDir, subDir); await fs.mkdir(targetDir, { recursive: true }); const fileNameOnDisk = `${md5Hash}${ext}`; const fullPath = path.join(targetDir, fileNameOnDisk); try { await fs.access(fullPath); } catch { await fs.writeFile(fullPath, buffer); } let extractedText: string | null = null; if ( mimeType.startsWith('text/') || mimeType === 'application/json' || mimeType === 'application/xml' || originalFilename.endsWith('.md') || originalFilename.endsWith('.txt') || originalFilename.endsWith('.csv') ) { extractedText = buffer.toString('utf-8').slice(0, 50000); } return { md5Hash, storagePath: path.join(subDir, fileNameOnDisk), sizeBytes: buffer.length, extractedText, }; } export function getFullAttachmentPath(storagePath: string): string { const baseDir = getAttachmentsDir(); const normalizedStoragePath = storagePath.replace(/\\/g, '/'); const resolved = path.resolve(baseDir, normalizedStoragePath); if (!resolved.startsWith(path.resolve(baseDir))) { throw new Error('Invalid storage path'); } return resolved; } export function guessExtensionFromMime(mime: string): string { const map: Record = { 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp', 'image/svg+xml': '.svg', 'application/pdf': '.pdf', 'audio/mpeg': '.mp3', 'audio/mp3': '.mp3', 'audio/wav': '.wav', 'audio/ogg': '.ogg', 'audio/webm': '.webm', 'audio/mp4': '.m4a', 'video/mp4': '.mp4', 'text/plain': '.txt', 'text/html': '.html', 'text/markdown': '.md', }; return map[mime.toLowerCase()] || '.bin'; } export function stripHtmlToText(html: string): string { if (!html) return ''; return html .replace(/]*>[\s\S]*?<\/style>/gi, ' ') .replace(/]*>[\s\S]*?<\/script>/gi, ' ') .replace(/<[^>]+>/g, ' ') .replace(/ /gi, ' ') .replace(/&/gi, '&') .replace(/</gi, '<') .replace(/>/gi, '>') .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/\s+/g, ' ') .trim(); } export function makeExcerpt(text: string, maxLen = 220): string { if (!text) return ''; const clean = text.replace(/\s+/g, ' ').trim(); return clean.length > maxLen ? clean.slice(0, maxLen) + '…' : clean; }