diff --git a/adapters/common/attachment/__tests__/attachment-store.test.ts b/adapters/common/attachment/__tests__/attachment-store.test.ts new file mode 100644 index 00000000..6f59c578 --- /dev/null +++ b/adapters/common/attachment/__tests__/attachment-store.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import * as os from 'node:os' +import { AttachmentStore } from '../attachment-store.js' + +let tmpRoot: string + +beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'att-store-test-')) +}) + +afterEach(async () => { + await fs.rm(tmpRoot, { recursive: true, force: true }) +}) + +describe('AttachmentStore', () => { + it('writes a buffer and returns the absolute path', async () => { + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const target = store.resolvePath('feishu', 'sess-1', 'hello.png') + const written = await store.write(target, Buffer.from('PNGDATA')) + expect(path.isAbsolute(written)).toBe(true) + const content = await fs.readFile(written) + expect(content.toString()).toBe('PNGDATA') + }) + + it('writes under {root}/{platform}/{sessionId}/', async () => { + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const target = store.resolvePath('telegram', 'sess-42', 'foo.pdf') + expect(target).toContain(path.join('telegram', 'sess-42')) + expect(target.endsWith('foo.pdf')).toBe(true) + }) + + it('sanitizes unsafe filenames (strips path separators and ..)', async () => { + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const target = store.resolvePath('feishu', 'sess-1', '../../etc/passwd') + // The resulting target must still live inside the store root. + const root = path.resolve(tmpRoot) + expect(path.resolve(target).startsWith(root)).toBe(true) + expect(path.basename(target)).not.toContain('..') + expect(path.basename(target)).not.toContain('/') + }) + + it('collapses name collisions by prefixing timestamps', async () => { + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const a = store.resolvePath('feishu', 'sess-1', 'image.png') + await store.write(a, Buffer.from('first')) + const b = store.resolvePath('feishu', 'sess-1', 'image.png') + expect(b).not.toBe(a) + await store.write(b, Buffer.from('second')) + const contentB = await fs.readFile(b) + expect(contentB.toString()).toBe('second') + }) + + it('gc() removes files older than retentionMs and reports counts', async () => { + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 50 }) + const target = store.resolvePath('feishu', 'sess-1', 'stale.png') + await store.write(target, Buffer.from('STALE')) + // Age the file manually + const past = new Date(Date.now() - 10_000) + await fs.utimes(target, past, past) + const result = await store.gc() + expect(result.removed).toBe(1) + expect(result.bytes).toBe(5) + await expect(fs.access(target)).rejects.toThrow() + }) + + it('gc() keeps fresh files', async () => { + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const target = store.resolvePath('feishu', 'sess-1', 'fresh.png') + await store.write(target, Buffer.from('FRESH')) + const result = await store.gc() + expect(result.removed).toBe(0) + await fs.access(target) + }) +}) diff --git a/adapters/common/attachment/attachment-store.ts b/adapters/common/attachment/attachment-store.ts new file mode 100644 index 00000000..12937b08 --- /dev/null +++ b/adapters/common/attachment/attachment-store.ts @@ -0,0 +1,106 @@ +/** + * Local staging directory for IM-downloaded resources. + * + * Layout: {root}/{platform}/{sessionId}/{safeName} + * Default root: ~/.claude/im-downloads + * + * Responsibilities: + * - Generate unique, safe paths from (platform, sessionId, originalName) + * - Atomic write (tmp → rename) so concurrent downloads never corrupt each other + * - GC files that haven't been touched for `retentionMs` (default 24h) + */ + +import * as fs from 'node:fs/promises' +import * as fsSync from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import type { ImPlatform } from './attachment-types.js' + +export interface AttachmentStoreConfig { + root: string + retentionMs: number +} + +const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000 + +function defaultRoot(): string { + return path.join(os.homedir(), '.claude', 'im-downloads') +} + +/** Strip path separators / .. / control chars from a filename. */ +function sanitizeFilename(name: string): string { + // eslint-disable-next-line no-control-regex + const base = path.basename(name || '').replace(/[\x00-\x1f]/g, '') + const cleaned = base.replace(/[\/\\]/g, '_').replace(/\.\.+/g, '_') + return cleaned.trim() || 'unnamed' +} + +export class AttachmentStore { + private readonly root: string + private readonly retentionMs: number + + constructor(config?: Partial) { + this.root = config?.root ?? defaultRoot() + this.retentionMs = config?.retentionMs ?? DEFAULT_RETENTION_MS + } + + /** Compute the target path. Creates parent dirs on demand. + * If a file with the same name already exists, prefix with a timestamp + * to avoid clobbering. */ + resolvePath(platform: ImPlatform, sessionId: string, name: string): string { + const safeSession = sanitizeFilename(sessionId) + const dir = path.join(this.root, platform, safeSession) + fsSync.mkdirSync(dir, { recursive: true }) + const safeName = sanitizeFilename(name) + const candidate = path.join(dir, safeName) + if (!fsSync.existsSync(candidate)) return candidate + const { name: base, ext } = path.parse(safeName) + return path.join(dir, `${base}-${Date.now()}${ext}`) + } + + /** Write atomically: stream to {target}.part, then rename. */ + async write(target: string, data: Buffer): Promise { + await fs.mkdir(path.dirname(target), { recursive: true }) + const tmp = `${target}.${process.pid}.${Date.now()}.part` + await fs.writeFile(tmp, data) + await fs.rename(tmp, target) + return target + } + + /** Remove files older than retentionMs. Returns summary. */ + async gc(): Promise<{ removed: number; bytes: number }> { + let removed = 0 + let bytes = 0 + const now = Date.now() + + const walk = async (dir: string): Promise => { + let entries: Awaited> + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + await walk(full) + } else if (entry.isFile()) { + try { + const stat = await fs.stat(full) + const age = now - stat.mtimeMs + if (age > this.retentionMs) { + bytes += stat.size + await fs.unlink(full) + removed++ + } + } catch { + // ignore races + } + } + } + } + + await walk(this.root).catch(() => {}) + return { removed, bytes } + } +}