mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
feat(adapters): add AttachmentStore for IM download staging
This commit is contained in:
parent
e2ea346754
commit
413437ced0
@ -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)
|
||||
})
|
||||
})
|
||||
106
adapters/common/attachment/attachment-store.ts
Normal file
106
adapters/common/attachment/attachment-store.ts
Normal file
@ -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<AttachmentStoreConfig>) {
|
||||
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<string> {
|
||||
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<void> => {
|
||||
let entries: Awaited<ReturnType<typeof fs.readdir>>
|
||||
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 }
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user