feat(skills): add skill deletion endpoint

- Add DELETE /api/v1/skill/remove endpoint
- Add validate_skill_name() for path traversal protection
- Include path normalization and security checks
- Prevent deletion of official skills (user skills only)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
朱潮 2026-01-07 21:34:16 +08:00
parent ac8782e1a7
commit 68a4578554

View File

@ -50,6 +50,18 @@ def validate_bot_id(bot_id: str) -> str:
return bot_id
def validate_skill_name(skill_name: str) -> str:
"""验证 skill_name 格式,防止路径遍历攻击"""
if not skill_name:
raise HTTPException(status_code=400, detail="skill_name 不能为空")
# 检查路径遍历字符
if '..' in skill_name or '/' in skill_name or '\\' in skill_name:
raise HTTPException(status_code=400, detail="skill_name 包含非法字符")
return skill_name
async def validate_upload_file_size(file: UploadFile) -> int:
"""验证上传文件大小,返回实际文件大小"""
file_size = 0
@ -411,3 +423,70 @@ async def upload_skill(file: UploadFile = File(...), bot_id: Optional[str] = For
logger.error(f"Error uploading skill file: {str(e)}")
# 不暴露详细错误信息给客户端(安全考虑)
raise HTTPException(status_code=500, detail="Skill文件上传失败")
@router.delete("/api/v1/skill/remove")
async def remove_skill(
bot_id: str = Query(..., description="Bot ID"),
skill_name: str = Query(..., description="Skill name to remove")
):
"""
删除用户上传的 skill
Args:
bot_id: Bot ID
skill_name: 要删除的 skill 名称
Returns:
dict: 删除结果
Notes:
- 只能删除用户上传的 skills不能删除官方 skills
- 删除路径: projects/uploads/{bot_id}/skills/{skill_name}
"""
try:
# 验证参数(防止路径遍历攻击)
bot_id = validate_bot_id(bot_id)
skill_name = validate_skill_name(skill_name)
logger.info(f"Skill remove - bot_id: {bot_id}, skill_name: {skill_name}")
# 构建删除目录路径
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
skill_dir = os.path.join(base_dir, "projects", "uploads", bot_id, "skills", skill_name)
# 规范化路径并确保在允许的目录内
skill_dir_real = os.path.realpath(skill_dir)
allowed_base = os.path.realpath(os.path.join(base_dir, "projects", "uploads", bot_id, "skills"))
if not skill_dir_real.startswith(allowed_base + os.sep):
raise HTTPException(status_code=403, detail="非法的删除路径")
# 检查目录是否存在
if not os.path.exists(skill_dir_real):
raise HTTPException(status_code=404, detail="Skill 不存在")
# 检查是否为目录
if not os.path.isdir(skill_dir_real):
raise HTTPException(status_code=400, detail="目标路径不是目录")
# 使用线程池删除目录(避免阻塞事件循环)
await asyncio.to_thread(shutil.rmtree, skill_dir_real)
logger.info(f"Successfully removed skill directory: {skill_dir_real}")
return {
"success": True,
"message": f"Skill '{skill_name}' 删除成功",
"bot_id": bot_id,
"skill_name": skill_name
}
except HTTPException:
raise
except Exception as e:
import traceback
error_details = traceback.format_exc()
logger.error(f"Error removing skill: {str(e)}")
logger.error(f"Full traceback: {error_details}")
raise HTTPException(status_code=500, detail="删除 Skill 失败")