diff --git a/src/api/server.ts b/src/api/server.ts index 45dbc95..58bb210 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -25,6 +25,70 @@ function dataDir(): string { return process.env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro'); } +/** 常见 MIME → 落盘扩展名(无前导点)。剪贴板粘贴 blob 常无扩展名,据此补齐。 */ +const MIME_EXT: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + 'image/bmp': 'bmp', + 'image/x-icon': 'ico', + 'image/vnd.microsoft.icon': 'ico', + 'image/tiff': 'tiff', + 'image/heic': 'heic', + 'image/heif': 'heif', + 'image/avif': 'avif', + 'application/pdf': 'pdf', + 'application/zip': 'zip', + 'application/json': 'json', + 'application/xml': 'xml', + 'text/plain': 'txt', + 'text/markdown': 'md', + 'text/csv': 'csv', + 'text/html': 'html', +}; + +/** MIME 类型 → 扩展名(无点)。未知映射时对 image/ 兜底取 subtype;其余返回 ''。 */ +export function extFromMime(mimetype: string | undefined): string { + if (!mimetype) return ''; + const key = mimetype.split(';')[0].trim().toLowerCase(); + if (MIME_EXT[key]) return MIME_EXT[key]; + const m = key.match(/^image\/([a-z0-9.+-]+)$/); + if (m) return m[1].replace(/[^a-z0-9]/g, ''); + return ''; +} + +/** basename 末尾是否带「看起来像扩展名」的后缀(1-8 位字母数字)。 */ +function hasExtension(name: string): boolean { + return /\.[A-Za-z0-9]{1,8}$/.test(name); +} + +/** + * 计算附件落盘安全文件名。规则: + * 1. 仅取 basename,净化非法字符(`[^\w.\-]` → `_`),剥掉前导点(防隐藏/空名)。 + * 2. 主名为空或为占位名(blob/image/file/unknown/untitled/paste)→ 视为「无名粘贴 blob」, + * 生成唯一名 `paste--`,扩展名优先取 mimetype 推断、其次保留原扩展名。 + * 3. 有正常主名但缺扩展名 → 按 mimetype 补扩展名(mimetype 未知则保持原样)。 + * 返回值保证非空。 + */ +export function safeAttachmentName(rawName: string | undefined, mimetype: string | undefined, seq = 0): string { + const ext = extFromMime(mimetype); + const sanitized = basename((rawName ?? '').trim()).replace(/[^\w.\-]/g, '_').replace(/^\.+/, ''); + const withExt = hasExtension(sanitized); + const stem = withExt ? sanitized.replace(/\.[A-Za-z0-9]{1,8}$/, '') : sanitized; + const isPlaceholder = stem === '' || /^(blob|image|file|unknown|untitled|paste)$/i.test(stem); + if (isPlaceholder) { + const origExt = withExt ? sanitized.slice(sanitized.lastIndexOf('.') + 1) : ''; + const useExt = ext || origExt; + const base = `paste-${Date.now()}-${seq}`; + return useExt ? `${base}.${useExt}` : base; + } + if (!withExt && ext) return `${sanitized}.${ext}`; + return sanitized; +} + /** Project 出参:附加 hasTodoJson(/todo/todo.json 是否存在,每次序列化时算) */ function projectOut(p: Project, store: Store): Project & { hasTodoJson: boolean; summary: ReturnType } { return { ...p, hasTodoJson: hasTodoJson(p.repoPath), summary: store.projectSummary(p.id) }; @@ -280,11 +344,13 @@ export function buildServer(opts: ApiOptions): FastifyInstance { const dir = join(dataDir(), 'tasks', id, 'attachments'); mkdirSync(dir, { recursive: true }); const saved: Array<{ name: string; type: string; path: string }> = []; + let seq = 0; for await (const part of req.files()) { - const safe = basename(part.filename).replace(/[^\w.\-]/g, '_') || `file-${Date.now()}`; + // 无名/无扩展名(如剪贴板粘贴 blob)→ 按 mimetype 生成安全文件名+扩展名 + const safe = safeAttachmentName(part.filename, part.mimetype, seq++); await pipeline(part.file, createWriteStream(join(dir, safe))); - if (part.file.truncated) return reply.code(400).send({ error: `文件 ${part.filename} 超过 25MB 上限` }); - saved.push({ name: part.filename, type: part.mimetype, path: `tasks/${id}/attachments/${safe}` }); + if (part.file.truncated) return reply.code(400).send({ error: `文件 ${part.filename || safe} 超过 25MB 上限` }); + saved.push({ name: part.filename || safe, type: part.mimetype, path: `tasks/${id}/attachments/${safe}` }); } if (saved.length === 0) return reply.code(400).send({ error: '未收到文件' }); return { attachments: store.addAttachments(id, saved) };