后端文件名加固:无名/无扩展名粘贴 blob 按 mimetype 生成安全文件名+扩展名 (tsk_5dmtoHD_QVA3)
附件上传接口对剪贴板粘贴等「无名/无扩展名」的 blob 加固落盘命名: - 新增 extFromMime / safeAttachmentName 纯函数(可单测、已导出) - 占位名(blob/image/file 等)或空名 → 生成唯一 paste-<ts>-<seq>, 扩展名优先按 mimetype 推断、其次保留原扩展名,避免多次粘贴同名覆盖 - 正常名缺扩展名 → 按 mimetype 补;路径穿越/非法字符净化,剥前导点 - name 字段在原名缺失时回退到生成名 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+69
-3
@@ -25,6 +25,70 @@ function dataDir(): string {
|
|||||||
return process.env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro');
|
return process.env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 常见 MIME → 落盘扩展名(无前导点)。剪贴板粘贴 blob 常无扩展名,据此补齐。 */
|
||||||
|
const MIME_EXT: Record<string, string> = {
|
||||||
|
'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> 兜底取 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-<ts>-<seq>`,扩展名优先取 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(<repoPath>/todo/todo.json 是否存在,每次序列化时算) */
|
/** Project 出参:附加 hasTodoJson(<repoPath>/todo/todo.json 是否存在,每次序列化时算) */
|
||||||
function projectOut(p: Project, store: Store): Project & { hasTodoJson: boolean; summary: ReturnType<Store['projectSummary']> } {
|
function projectOut(p: Project, store: Store): Project & { hasTodoJson: boolean; summary: ReturnType<Store['projectSummary']> } {
|
||||||
return { ...p, hasTodoJson: hasTodoJson(p.repoPath), summary: store.projectSummary(p.id) };
|
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');
|
const dir = join(dataDir(), 'tasks', id, 'attachments');
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
const saved: Array<{ name: string; type: string; path: string }> = [];
|
const saved: Array<{ name: string; type: string; path: string }> = [];
|
||||||
|
let seq = 0;
|
||||||
for await (const part of req.files()) {
|
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)));
|
await pipeline(part.file, createWriteStream(join(dir, safe)));
|
||||||
if (part.file.truncated) return reply.code(400).send({ error: `文件 ${part.filename} 超过 25MB 上限` });
|
if (part.file.truncated) return reply.code(400).send({ error: `文件 ${part.filename || safe} 超过 25MB 上限` });
|
||||||
saved.push({ name: part.filename, type: part.mimetype, path: `tasks/${id}/attachments/${safe}` });
|
saved.push({ name: part.filename || safe, type: part.mimetype, path: `tasks/${id}/attachments/${safe}` });
|
||||||
}
|
}
|
||||||
if (saved.length === 0) return reply.code(400).send({ error: '未收到文件' });
|
if (saved.length === 0) return reply.code(400).send({ error: '未收到文件' });
|
||||||
return { attachments: store.addAttachments(id, saved) };
|
return { attachments: store.addAttachments(id, saved) };
|
||||||
|
|||||||
Reference in New Issue
Block a user