]> acesimba.cloud Git - codebuddy-web.git/commitdiff
fix(skills): 上传支持扁平 zip 与自动命名
authorCodebuddy <codebuddy@localhost>
Fri, 14 Aug 2026 12:37:58 +0000 (20:37 +0800)
committerCodebuddy <codebuddy@localhost>
Fri, 14 Aug 2026 12:37:58 +0000 (20:37 +0800)
- 解压跳过 __MACOSX / ._* 系统文件
- 支持扁平结构(技能内容直接压缩,无统一顶层目录):原样解压
- 扁平时从 SKILL.md frontmatter 的 name 自动取 skill 名称,缺省回退到文件名
- 仅『恰好 1 个顶层目录』才剥前缀(单目录包裹场景)

backend/app.py

index 20d26583325655819f708de16a61b3bfb96809c0..2488c58e8af34dc8a242451b5991ef1dd1e75f04 100644 (file)
@@ -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)