import fs from 'fs'; import crypto from 'crypto'; import { paths } from '../shared/paths.js'; export interface SavedFile { type: 'image' | 'document'; name: string; mediaType: string; relPath: string; absPath: string; } /** Per-file decoded-byte ceiling. Anything larger is rejected at the save chokepoint * so a single message can't write an unbounded blob to disk. */ export const MAX_ATTACHMENT_BYTES = 12 * 1024 * 1024; /** Per-message guards (enforced by callers around the save loop). */ export const MAX_ATTACHMENTS_PER_MESSAGE = 12; export const MAX_TOTAL_ATTACHMENT_BYTES = 48 * 1024 * 1024; export function ensureFileDirs(): void { fs.mkdirSync(paths.filesAudio, { recursive: true }); fs.mkdirSync(paths.filesImages, { recursive: true }); fs.mkdirSync(paths.filesDocuments, { recursive: true }); } const EXT_FROM_MIME: Record = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/avif': 'avif', 'image/bmp': 'bmp', 'image/heic': 'heic', 'image/heif': 'heif', 'image/svg+xml': 'svg', 'application/pdf': 'pdf', 'text/plain': 'txt', 'text/markdown': 'md', 'text/csv': 'csv', 'application/json': 'json', 'application/xml': 'xml', 'text/html': 'html', 'application/zip': 'zip', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', }; /** Dependency-free magic-byte sniff for the common binary types. Returns the detected * media type or undefined when the bytes aren't a recognized signature. We trust this * over the client-claimed mediaType when it fires — the client controls both name and * mediaType, so the on-disk extension / served Content-Type must derive from content. */ function sniffMediaType(buf: Buffer): string | undefined { if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'image/png'; if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'; if (buf.length >= 6) { const s = buf.toString('latin1', 0, 6); if (s === 'GIF87a' || s === 'GIF89a') return 'image/gif'; } if (buf.length >= 12 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') return 'image/webp'; if (buf.length >= 5 && buf.toString('latin1', 0, 5) === '%PDF-') return 'application/pdf'; if (buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && (buf[2] === 0x03 || buf[2] === 0x05 || buf[2] === 0x07)) return 'application/zip'; return undefined; } /** Strip path separators / control chars from a client-supplied display name and bound * its length. Never used to build the on-disk filename (that is random) — only for the * persisted/displayed name. */ function sanitizeName(name?: string): string { if (!name) return ''; return name.replace(/[/\\\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 200); } function stampPrefix(): string { const now = new Date(); const ts = now.toISOString().replace(/[-:T]/g, '').slice(0, 14); return `${ts.slice(0, 8)}_${ts.slice(8, 14)}_${crypto.randomBytes(3).toString('hex')}`; } export function saveAttachment(att: { type: 'image' | 'file'; name?: string; mediaType: string; data: string }): SavedFile { const buf = Buffer.from(att.data || '', 'base64'); if (buf.length === 0) throw new Error('empty attachment payload'); if (buf.length > MAX_ATTACHMENT_BYTES) { throw new Error(`attachment too large: ${buf.length} bytes (max ${MAX_ATTACHMENT_BYTES})`); } // Content is authoritative. When we recognize the bytes, the sniffed type drives the // media type, the on-disk category, and the rendered image/doc classification — so a // client can't mislabel a PDF as image/png (or vice-versa) to land it in the wrong bucket. const sniffed = sniffMediaType(buf); const claimedImage = att.type === 'image' || (att.mediaType || '').toLowerCase().startsWith('image/'); const isImage = sniffed ? sniffed.startsWith('image/') : claimedImage; const effectiveMediaType = sniffed || att.mediaType || (isImage ? 'image/jpeg' : 'application/octet-stream'); const category = isImage ? 'images' : 'documents'; const cleanName = sanitizeName(att.name); // Extension: prefer the validated/sniffed media type, then a sanitized client extension, // then a generic fallback. (Never trust the raw client name for the on-disk path.) const extFromName = cleanName.includes('.') ? cleanName.split('.').pop()!.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 8) : ''; const ext = EXT_FROM_MIME[effectiveMediaType] || extFromName || 'bin'; const filename = `${stampPrefix()}.${ext}`; const relPath = `${category}/${filename}`; const dir = isImage ? paths.filesImages : paths.filesDocuments; const absPath = `${dir}/${filename}`; fs.writeFileSync(absPath, buf); return { type: isImage ? 'image' : 'document', name: cleanName, mediaType: effectiveMediaType, relPath, absPath, }; } /** Persist a voice/audio clip (raw base64) to files/audio and return its served path. * Audio rides on a message as meta.audio_data = relPath (not in the attachments array), * so the chat can replay it after a refresh. */ export function saveAudio(base64: string, mediaType = 'audio/webm'): { relPath: string; absPath: string; mediaType: string } { const buf = Buffer.from(base64 || '', 'base64'); if (buf.length === 0) throw new Error('empty audio payload'); if (buf.length > MAX_ATTACHMENT_BYTES) throw new Error(`audio too large: ${buf.length} bytes`); const mt = (mediaType || 'audio/webm').toLowerCase(); const ext = mt.includes('webm') ? 'webm' : (mt.includes('mp4') || mt.includes('m4a') || mt.includes('aac')) ? 'm4a' : (mt.includes('mpeg') || mt.includes('mp3')) ? 'mp3' : mt.includes('wav') ? 'wav' : mt.includes('ogg') ? 'ogg' : 'webm'; const filename = `${stampPrefix()}.${ext}`; const relPath = `audio/${filename}`; const absPath = `${paths.filesAudio}/${filename}`; fs.writeFileSync(absPath, buf); return { relPath, absPath, mediaType: mediaType || 'audio/webm' }; }