后端:附件读取/下载端点 + 删除端点 + addAttachments 落库去重 (tsk_tttdU3VLCy50)
- store.addAttachments:按 path 去重(later-wins),消除重传产生的悬空元数据 - store.removeAttachment:按磁盘文件名(basename(path))移除元数据,返回 removed - GET /api/tasks/:id/attachments/:name:inline 预览,?download=1 触发下载(RFC5987 文件名) - DELETE /api/tasks/:id/attachments/:name:先删元数据再尽力删磁盘文件 - 只服务元数据登记过的附件 + resolve/startsWith 前缀校验,杜绝目录穿越 - 新增 test/attachments.test.ts(去重/读取/下载/删除/穿越/丢失/端到端上传去重) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+44
-2
@@ -12,10 +12,10 @@ import { acceptAndMerge } from '../executor/exec-merge.js';
|
||||
import { createWorktree, git } from '../executor/worktree.js';
|
||||
import { resolveLogo, LOGO_MIME } from './logo.js';
|
||||
import { readTranscript, TranscriptError } from '../executor/transcript.js';
|
||||
import { createReadStream, createWriteStream, mkdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { createReadStream, createWriteStream, mkdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { transcriptDir } from '../executor/cc.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, basename } from 'node:path';
|
||||
import { join, basename, resolve, sep } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import multipart from '@fastify/multipart';
|
||||
import { createUsageFetcher, type UsageInfo } from '../daemon/usage.js';
|
||||
@@ -356,6 +356,48 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
||||
return { attachments: store.addAttachments(id, saved) };
|
||||
});
|
||||
|
||||
// 附件读取/预览/下载:默认 inline(图片可 <img> 预览);?download=1 触发下载。
|
||||
// 标识符为磁盘文件名 :name(= basename(att.path)),只服务元数据里登记过的附件。
|
||||
app.get('/api/tasks/:id/attachments/:name', (req, reply) => {
|
||||
const { id, name } = req.params as { id: string; name: string };
|
||||
const task = store.getTask(id);
|
||||
if (!task) return reply.code(404).send({ error: `任务不存在: ${id}` });
|
||||
const att = (task.attachments ?? []).find((a) => basename(a.path) === name);
|
||||
if (!att) return reply.code(404).send({ error: `附件不存在: ${name}` });
|
||||
|
||||
const abs = resolve(join(dataDir(), att.path));
|
||||
// 纵深防御:解析后必须仍在该任务 attachments 目录内
|
||||
const baseDir = resolve(join(dataDir(), 'tasks', id, 'attachments'));
|
||||
if (abs !== baseDir && !abs.startsWith(baseDir + sep))
|
||||
return reply.code(400).send({ error: '附件路径越界' });
|
||||
let st;
|
||||
try { st = statSync(abs); } catch { return reply.code(404).send({ error: '附件文件已丢失' }); }
|
||||
|
||||
const dl = (req.query as { download?: string }).download;
|
||||
const fallbackName = att.name || name;
|
||||
reply
|
||||
.header('cache-control', 'private, max-age=300')
|
||||
.header('content-length', String(st.size))
|
||||
.header('content-disposition',
|
||||
`${dl ? 'attachment' : 'inline'}; filename*=UTF-8''${encodeURIComponent(fallbackName)}`)
|
||||
.type(att.type || 'application/octet-stream');
|
||||
return reply.send(createReadStream(abs));
|
||||
});
|
||||
|
||||
// 删除单个附件:先删元数据(拿到 removed),再尽力删磁盘文件
|
||||
app.delete('/api/tasks/:id/attachments/:name', (req, reply) => {
|
||||
const { id, name } = req.params as { id: string; name: string };
|
||||
if (!store.getTask(id)) return reply.code(404).send({ error: `任务不存在: ${id}` });
|
||||
const { attachments, removed } = store.removeAttachment(id, name);
|
||||
if (!removed) return reply.code(404).send({ error: `附件不存在: ${name}` });
|
||||
const abs = resolve(join(dataDir(), removed.path));
|
||||
const baseDir = resolve(join(dataDir(), 'tasks', id, 'attachments'));
|
||||
if (abs === baseDir || abs.startsWith(baseDir + sep)) {
|
||||
try { rmSync(abs, { force: true }); } catch { /* 磁盘文件已不在,忽略 */ }
|
||||
}
|
||||
return { attachments };
|
||||
});
|
||||
|
||||
// 人工接管:准备(或复用)任务 worktree,返回用户在自己终端起交互式 claude 的命令。
|
||||
// 适用卡住/需人工的任务——人接手手动改。不在 daemon 内起交互会话(无 TTY)。
|
||||
app.post('/api/tasks/:id/takeover', async (req, reply) => {
|
||||
|
||||
Reference in New Issue
Block a user