agent-ecosystem/src/main/services/team/TeamAttachmentStore.ts

286 lines
9 KiB
TypeScript

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<Map<string, string>> {
const filePaths = new Map<string, string>();
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<Map<string, string>> {
const result = new Map<string, string>();
// 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<AttachmentFileData[]> {
// 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<AttachmentFileData[] | null> {
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<AttachmentFileData[]> {
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<AttachmentFileData>;
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.
}