import { getAppDataPath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; import type { AttachmentFileData, AttachmentPayload } from '@shared/types'; const logger = createLogger('Service:TeamAttachmentStore'); const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB per file const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB legacy JSON cap /** Per-attachment metadata stored in the index file. */ interface StoredAttachmentIndex { id: string; filename: string; mimeType: string; } export class TeamAttachmentStore { private assertSafePathSegment(label: string, value: string): void { if ( value.length === 0 || value.trim().length === 0 || value === '.' || value === '..' || value.includes('/') || value.includes('\\') || value.includes('..') || value.includes('\0') ) { throw new Error(`Invalid ${label}`); } } private sanitizeStoredFilename(original: string): string { const raw = String(original ?? '').trim(); const base = raw ? (raw.split(/[\\/]/).pop() ?? raw) : ''; const cleaned = base .replace(/\0/g, '') .replace(/[\r\n\t]/g, ' ') .replace(/[\\/]/g, '_') .trim(); if (!cleaned) return 'attachment'; return cleaned.length > 180 ? cleaned.slice(0, 180) : cleaned; } /** Base directory for all message attachments of a team. */ private getTeamDir(teamName: string): string { this.assertSafePathSegment('teamName', teamName); return path.join(getAppDataPath(), 'attachments', teamName); } /** Directory for a specific message's attachments (new file-based format). */ private getMessageDir(teamName: string, messageId: string): string { this.assertSafePathSegment('messageId', messageId); return path.join(this.getTeamDir(teamName), messageId); } /** Path to the metadata index file inside a message attachment directory. */ private getIndexPath(teamName: string, messageId: string): string { return path.join(this.getMessageDir(teamName, messageId), '_index.json'); } /** Legacy JSON bundle path (old format). */ private getLegacyFilePath(teamName: string, messageId: string): string { this.assertSafePathSegment('messageId', messageId); return path.join(this.getTeamDir(teamName), `${messageId}.json`); } /** Stored file path for an individual attachment. */ private getStoredFilePath( teamName: string, messageId: string, attachmentId: string, filename: string ): string { this.assertSafePathSegment('attachmentId', attachmentId); const safeName = this.sanitizeStoredFilename(filename); return path.join(this.getMessageDir(teamName, messageId), `${attachmentId}--${safeName}`); } /** * Save message attachments as individual files on disk. * Returns a Map of attachmentId → absolute file path for each saved file. */ async saveAttachments( teamName: string, messageId: string, attachments: AttachmentPayload[] ): Promise> { const filePaths = new Map(); if (attachments.length === 0) return filePaths; const dir = this.getMessageDir(teamName, messageId); await fs.promises.mkdir(dir, { recursive: true }); const indexEntries: StoredAttachmentIndex[] = []; for (const att of attachments) { const buffer = Buffer.from(att.data, 'base64'); if (buffer.length > MAX_ATTACHMENT_SIZE) { logger.warn( `[${teamName}] Skipping oversized attachment ${att.id} (${(buffer.length / (1024 * 1024)).toFixed(1)} MB)` ); continue; } const storedPath = this.getStoredFilePath(teamName, messageId, att.id, att.filename); try { await atomicWriteAsync(storedPath, buffer); } catch (writeError) { logger.warn(`[${teamName}] Failed to write attachment ${att.id}: ${writeError}`); continue; } filePaths.set(att.id, storedPath); indexEntries.push({ id: att.id, filename: att.filename, mimeType: att.mimeType, }); } // Write metadata index for successful files (mimeType, original filename) if (indexEntries.length > 0) { const indexPath = this.getIndexPath(teamName, messageId); await atomicWriteAsync(indexPath, JSON.stringify(indexEntries, null, 2)); } logger.debug( `[${teamName}] Saved ${filePaths.size} attachment file(s) for message ${messageId}` ); return filePaths; } /** * Returns a Map of attachmentId → absolute file path. * Only available for new file-based format. Legacy JSON bundles have no individual file paths. */ async getAttachmentFilePaths(teamName: string, messageId: string): Promise> { const result = new Map(); // Try new file-based format first const dir = this.getMessageDir(teamName, messageId); try { const entries = await fs.promises.readdir(dir); for (const entry of entries) { if (entry === '_index.json') continue; const dashIdx = entry.indexOf('--'); if (dashIdx > 0) { const attachmentId = entry.slice(0, dashIdx); result.set(attachmentId, path.join(dir, entry)); } } if (result.size > 0) return result; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } // No new-format files found — not available for legacy format return result; } /** * Read attachment data (base64) for rendering in UI. * Supports both new file-based format and legacy JSON bundle. */ async getAttachments(teamName: string, messageId: string): Promise { // Try new file-based format first const newResult = await this.readNewFormatAttachments(teamName, messageId); if (newResult !== null) return newResult; // Fallback to legacy JSON format return this.readLegacyAttachments(teamName, messageId); } /** Read attachments from new file-based directory format. */ private async readNewFormatAttachments( teamName: string, messageId: string ): Promise { const indexPath = this.getIndexPath(teamName, messageId); let indexRaw: string; try { indexRaw = await fs.promises.readFile(indexPath, 'utf8'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; } let index: StoredAttachmentIndex[]; try { const parsed = JSON.parse(indexRaw) as unknown; if (!Array.isArray(parsed)) return null; index = parsed as StoredAttachmentIndex[]; } catch { return null; } const result: AttachmentFileData[] = []; for (const entry of index) { if (!entry || typeof entry.id !== 'string') continue; const filename = typeof entry.filename === 'string' && entry.filename ? entry.filename : 'attachment'; const mimeType = typeof entry.mimeType === 'string' && entry.mimeType ? entry.mimeType : 'application/octet-stream'; const filePath = this.getStoredFilePath(teamName, messageId, entry.id, filename); try { const buffer = await fs.promises.readFile(filePath); result.push({ id: entry.id, data: buffer.toString('base64'), mimeType, filePath, }); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; throw error; } } return result; } /** Read attachments from legacy JSON bundle format. */ private async readLegacyAttachments( teamName: string, messageId: string ): Promise { const filePath = this.getLegacyFilePath(teamName, messageId); let raw: string; try { const stat = await fs.promises.stat(filePath); if (!stat.isFile() || stat.size > MAX_ATTACHMENTS_FILE_BYTES) { return []; } raw = await fs.promises.readFile(filePath, 'utf8'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return []; } throw error; } let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { return []; } if (!Array.isArray(parsed)) { return []; } const result: AttachmentFileData[] = []; for (const item of parsed) { if (!item || typeof item !== 'object') continue; const row = item as Partial; if ( typeof row.id !== 'string' || typeof row.data !== 'string' || typeof row.mimeType !== 'string' ) { continue; } result.push({ id: row.id, data: row.data, mimeType: row.mimeType, }); } return result; } // TODO: add deleteAttachments(teamName, messageId) for cleanup on failed/cancelled sends. // Best-effort removal of attachment files — useful for retry/cancel flows. }