From: Codebuddy Date: Fri, 14 Aug 2026 12:37:58 +0000 (+0800) Subject: fix(skills): 上传支持扁平 zip 与自动命名 X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=fa972e152b0759b87e11bae550f62f5509da22b0;p=codebuddy-web.git fix(skills): 上传支持扁平 zip 与自动命名 - 解压跳过 __MACOSX / ._* 系统文件 - 支持扁平结构(技能内容直接压缩,无统一顶层目录):原样解压 - 扁平时从 SKILL.md frontmatter 的 name 自动取 skill 名称,缺省回退到文件名 - 仅『恰好 1 个顶层目录』才剥前缀(单目录包裹场景) --- diff --git a/backend/app.py b/backend/app.py index 20d2658..2488c58 100644 --- a/backend/app.py +++ b/backend/app.py @@ -826,26 +826,60 @@ def _list_skills(): return out +def _real_entries(zf: zipfile.ZipFile): + """返回需处理的 (info, name, parts),跳过 __MACOSX 与 ._* 系统文件。""" + out = [] + for info in zf.infolist(): + name = info.filename + if name == "__MACOSX" or name.startswith("__MACOSX/"): + continue + parts = name.split("/") + if any(p.startswith("._") for p in parts): + continue + out.append((info, name, parts)) + return out + + +def _skill_name_from_zip(zf: zipfile.ZipFile): + """从 zip 内根目录的 SKILL.md frontmatter 提取 name(扁平结构也能用)。""" + for info, name, parts in _real_entries(zf): + if len(parts) == 1 and parts[0].lower() == "skill.md": + try: + txt = zf.read(info).decode("utf-8", "replace") + except Exception: + return None + nm, _ = _parse_skill_frontmatter(txt) + return nm + return None + + def _extract_skill_zip(zf: zipfile.ZipFile, dest: Path): - """把 zip 内容(去掉单个顶层目录)解压到 dest,防 zip-slip。""" + """把 zip 内容解压到 dest,防 zip-slip。 + + - 跳过 __MACOSX / ._* 系统文件 + - 若 zip 以单个顶层目录组织(my-skill/...),去掉该前缀 + - 若为扁平结构(SKILL.md 等直接在根),原样解压 + """ + entries = _real_entries(zf) + if not entries: + raise ValueError("zip 为空或仅含系统文件") + tops = set(p[0] for info, name, p in entries if len(p) > 1 and p[0]) + if len(tops) == 1: + top = tops.pop() + strip = True + else: + # 0 个或多个顶层目录(如技能内容直接压缩、无统一包装目录)→ 扁平解压 + top = None + strip = False dest.mkdir(parents=True, exist_ok=True) - tops = set() - for n in zf.namelist(): - p = n.split("/")[0] - if p: - tops.add(p) - if len(tops) != 1: - raise ValueError("zip 须以单个 skill 目录为顶层(如 my-skill/...)") - top = tops.pop() - for info in zf.infolist(): - rel = info.filename - if rel == top or rel.startswith(top + "/"): - rel = rel[len(top) + 1:] - if not rel: + for info, name, p in entries: + rel = name[len(top) + 1:] if (strip and top) else name + rel = rel.rstrip("/") + if rel == "": continue target = (dest / rel).resolve() if dest.resolve() not in target.parents and target != dest.resolve(): - raise ValueError("zip 含非法路径(zip-slip): " + info.filename) + raise ValueError("zip 含非法路径(zip-slip): " + name) if info.is_dir(): target.mkdir(parents=True, exist_ok=True) else: @@ -923,11 +957,17 @@ async def api_upload_skill(task_id: str, request: Request): zf = _decode_zip_body(body) except ValueError as e: return JSONResponse({"error": str(e)}, status_code=400) - tops = set(n.split("/")[0] for n in zf.namelist() if n.split("/")[0]) - name = body.get("name") or (tops.pop() if len(tops) == 1 else None) - if not name: - zf.close() - return JSONResponse({"error": "无法确定 skill 名称:zip 需以单个目录为顶层,或提供 name 字段"}, status_code=400) + entries = _real_entries(zf) + tops = set(p[0] for info, name, p in entries if len(p) > 1 and p[0]) + provided = body.get("name") + if provided: + name = provided + elif len(tops) == 1: + name = tops.pop() + else: + # 扁平结构(0 个或多个顶层目录,如技能内容直接压缩):优先用 SKILL.md frontmatter 的 name + fallback = (body.get("filename") or "skill").rsplit(".", 1)[0] + name = _skill_name_from_zip(zf) or fallback if not _NAME_RE.match(name): zf.close() return JSONResponse({"error": f"skill 名称非法: {name}(仅允许字母数字 - _,长度 1-64)"}, status_code=400)