agent-ecosystem/src/main/services/team/TeamTaskAttachmentStore.ts
iliya c30184052a feat: add TeamBackupService for team data persistence
- Auto-backup team files (config, tasks, inboxes, attachments, etc.)
  to app's own storage (Electron userData) every 3 minutes
- Sync backup on app shutdown (after SIGKILL, before cleanup)
- Auto-restore missing/corrupted teams on startup with identity guard
- Move attachments to app-owned storage (outside ~/.claude/)
- Add stopAllTeams() with SIGKILL for reliable shutdown
- Clean up app data when permanently deleting teams
- Generation-based shutdown isolation prevents async/sync races
2026-03-15 15:51:52 +02:00

188 lines
5.8 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 type { AttachmentMediaType, TaskAttachmentMeta } from '@shared/types';
const logger = createLogger('Service:TeamTaskAttachmentStore');
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB
export class TeamTaskAttachmentStore {
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}`);
}
}
/** Returns the directory for a specific task's attachments. */
private getTaskDir(teamName: string, taskId: string): string {
this.assertSafePathSegment('teamName', teamName);
this.assertSafePathSegment('taskId', taskId);
return path.join(getAppDataPath(), 'task-attachments', teamName, taskId);
}
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';
// Keep filenames bounded to avoid OS/path length issues.
return cleaned.length > 180 ? cleaned.slice(0, 180) : cleaned;
}
/** Returns the file path for a stored attachment (new format). */
private getStoredFilePath(
teamName: string,
taskId: string,
attachmentId: string,
filename: string
): string {
this.assertSafePathSegment('attachmentId', attachmentId);
const safeName = this.sanitizeStoredFilename(filename);
return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}--${safeName}`);
}
private async findAttachmentFilePath(
teamName: string,
taskId: string,
attachmentId: string,
_mimeType?: string
): Promise<string | null> {
const dir = this.getTaskDir(teamName, taskId);
// Canonical format: "<id>--<filename>"
try {
const entries = await fs.promises.readdir(dir);
const prefix = `${attachmentId}--`;
const matches = entries.filter((e) => e.startsWith(prefix));
if (matches.length > 0) {
return path.join(dir, matches[0]);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
// Non-directory or other IO errors should surface.
throw error;
}
return null;
}
/**
* Save an attachment to disk. Data is expected as a base64-encoded string.
* Returns metadata for the saved attachment.
*/
async saveAttachment(
teamName: string,
taskId: string,
attachmentId: string,
filename: string,
mimeType: AttachmentMediaType,
base64Data: string
): Promise<TaskAttachmentMeta> {
const trimmed = base64Data.trim();
// Avoid allocating huge Buffers for obviously too-large payloads.
// Base64 decoded size is roughly 3/4 of the string length minus padding.
const padding = trimmed.endsWith('==') ? 2 : trimmed.endsWith('=') ? 1 : 0;
const estimatedBytes = Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding);
if (estimatedBytes > MAX_ATTACHMENT_SIZE) {
throw new Error(
`Attachment too large: ${(estimatedBytes / (1024 * 1024)).toFixed(1)} MB (max ${MAX_ATTACHMENT_SIZE / (1024 * 1024)} MB)`
);
}
const buffer = Buffer.from(trimmed, 'base64');
if (buffer.length > MAX_ATTACHMENT_SIZE) {
throw new Error(
`Attachment too large: ${(buffer.length / (1024 * 1024)).toFixed(1)} MB (max ${MAX_ATTACHMENT_SIZE / (1024 * 1024)} MB)`
);
}
const dir = this.getTaskDir(teamName, taskId);
await fs.promises.mkdir(dir, { recursive: true });
const filePath = this.getStoredFilePath(teamName, taskId, attachmentId, filename);
await fs.promises.writeFile(filePath, buffer);
const meta: TaskAttachmentMeta = {
id: attachmentId,
filename,
mimeType,
size: buffer.length,
addedAt: new Date().toISOString(),
};
logger.debug(`[${teamName}] Saved task attachment ${attachmentId} for task #${taskId}`);
return meta;
}
/**
* Read an attachment file and return its base64 data.
*/
async getAttachment(
teamName: string,
taskId: string,
attachmentId: string,
mimeType: AttachmentMediaType
): Promise<string | null> {
const filePath = await this.findAttachmentFilePath(teamName, taskId, attachmentId, mimeType);
if (!filePath) return null;
try {
const buffer = await fs.promises.readFile(filePath);
return buffer.toString('base64');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw error;
}
}
/**
* Delete an attachment file from disk.
*/
async deleteAttachment(
teamName: string,
taskId: string,
attachmentId: string,
mimeType: AttachmentMediaType
): Promise<void> {
const filePath = await this.findAttachmentFilePath(teamName, taskId, attachmentId, mimeType);
if (!filePath) return;
try {
await fs.promises.unlink(filePath);
logger.debug(`[${teamName}] Deleted task attachment ${attachmentId} for task #${taskId}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
// Clean up empty directory
const dir = this.getTaskDir(teamName, taskId);
try {
const entries = await fs.promises.readdir(dir);
if (entries.length === 0) {
await fs.promises.rmdir(dir);
}
} catch {
// ignore cleanup errors
}
}
}