diff --git a/README.md b/README.md index 5cee8725..4e75efb8 100644 --- a/README.md +++ b/README.md @@ -301,22 +301,26 @@ Run it against a local backend: SkillHub works as a skill registry backend for several agent platforms. Point any of the clients below at your SkillHub instance to publish, discover, and install skills. -### [openclaw](https://github.com/openclaw/openclaw) +### [OpenClaw](https://github.com/openclaw/openclaw) -[openclaw](https://github.com/openclaw/openclaw) is an open-source agent skill CLI. Configure it to use your SkillHub endpoint as the registry: +[OpenClaw](https://github.com/openclaw/openclaw) is an open-source agent skill CLI. Configure it to use your SkillHub endpoint as the registry: ```bash -# Log in to your SkillHub instance -openclaw login --registry https:// - -# Publish a skill -openclaw push +# Configure registry URL +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +export CLAWHUB_API_TOKEN=YOUR_API_TOKEN # Search and install skills -openclaw search -openclaw install / +npx clawhub search email +npx clawhub install my-skill +npx clawhub install my-namespace--my-skill + +# Publish a skill +npx clawhub publish ./my-skill ``` +📖 **[Complete OpenClaw Integration Guide →](./docs/openclaw-integration.md)** + ### [AstronClaw](https://agent.xfyun.cn/astron-claw) [AstronClaw](https://agent.xfyun.cn/astron-claw) is the skill marketplace provided by iFlytek's Astron platform. You can connect it to a self-hosted SkillHub registry to manage and distribute private skills within your organization, or browse publicly shared skills on the Astron platform. diff --git a/README_zh.md b/README_zh.md index 199674ef..ee2e5b6d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -255,6 +255,26 @@ SkillHub 采用清晰的分层架构: SkillHub 设计为与各种智能体平台和框架无缝集成。 +### [OpenClaw](https://github.com/openclaw/openclaw) + +[OpenClaw](https://github.com/openclaw/openclaw) 是开源的智能体技能 CLI 工具。配置它使用您的 SkillHub 端点作为注册中心: + +```bash +# 配置注册中心地址 +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +export CLAWHUB_API_TOKEN=YOUR_API_TOKEN + +# 搜索和安装技能 +npx clawhub search email +npx clawhub install my-skill +npx clawhub install my-namespace--my-skill + +# 发布技能 +npx clawhub publish ./my-skill +``` + +📖 **[完整 OpenClaw 集成指南 →](./docs/openclaw-integration.md)** + ### [AstronClaw](https://agent.xfyun.cn/astron-claw) [AstronClaw](https://agent.xfyun.cn/astron-claw) 是科大讯飞星火平台提供的技能市场。您可以将其连接到自托管的 SkillHub 注册中心,在组织内管理和分发私有技能,或在星火平台上浏览公开共享的技能。 diff --git a/docs/openclaw-integration-en.md b/docs/openclaw-integration-en.md new file mode 100644 index 00000000..554d17ae --- /dev/null +++ b/docs/openclaw-integration-en.md @@ -0,0 +1,307 @@ +# OpenClaw Integration Guide + +This document explains how to configure OpenClaw CLI to connect to a SkillHub private registry for publishing, searching, and downloading skills. + +## Overview + +SkillHub provides a ClawHub-compatible API layer, allowing OpenClaw CLI to seamlessly integrate with private registries. With simple configuration, you can: + +- 🔍 Search for private skills within your organization +- 📥 Download and install skill packages +- 📤 Publish new skills to the private registry +- ⭐ Star and rate skills + +## Quick Start + +### 1. Configure Registry URL + +Set the SkillHub registry address in your OpenClaw configuration: + +```bash +# Via environment variable +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +``` + +### 2. Authentication (Optional) + +For **global namespace (@global) PUBLIC skills**, no login is required to download. Authentication is required for: + +- Team namespace skills (regardless of visibility) +- NAMESPACE_ONLY or PRIVATE skills +- Write operations like publishing, starring, etc. + +```bash +# Using API Token +export CLAWHUB_API_TOKEN=YOUR_API_TOKEN +``` + +#### Obtaining an API Token + +1. Log in to SkillHub Web UI +2. Navigate to **Settings → API Tokens** +3. Click **Create New Token** +4. Set token name and permissions +5. Copy the generated token + +### 3. Search Skills + +```bash +# Search for skills +npx clawhub search email + +# View skill details +npx clawhub info my-skill +``` + +### 4. Install Skills + +```bash +# Install latest version +npx clawhub install my-skill + +# Install specific version +npx clawhub install my-skill@1.2.0 + +# Install skill from team namespace +npx clawhub install my-namespace--my-skill +``` + +### 5. Publish Skills + +```bash +# Publish a skill (requires appropriate permissions) +npx clawhub publish ./my-skill +``` + +## API Endpoints + +SkillHub compatibility layer provides the following endpoints: + +| Endpoint | Method | Description | Auth Required | +|----------|--------|-------------|---------------| +| `/api/v1/whoami` | GET | Get current user info | Yes | +| `/api/v1/search` | GET | Search skills | Optional | +| `/api/v1/resolve` | GET | Resolve skill version | Optional | +| `/api/v1/download/{slug}` | GET | Download skill (redirect) | Optional* | +| `/api/v1/download` | GET | Download skill (query params) | Optional* | +| `/api/v1/skills/{slug}` | GET | Get skill details | Optional | +| `/api/v1/skills/{slug}/star` | POST | Star a skill | Yes | +| `/api/v1/skills/{slug}/unstar` | DELETE | Unstar a skill | Yes | +| `/api/v1/publish` | POST | Publish a skill | Yes | + +\* Download endpoint authentication requirements: +- **Global namespace (@global) PUBLIC skills**: No authentication required +- **All team namespace skills**: Authentication required +- **NAMESPACE_ONLY and PRIVATE skills**: Authentication required + +## Skill Visibility Levels + +SkillHub supports three visibility levels with the following download permission rules: + +### PUBLIC +- ✅ Anyone can search and view +- ✅ **Global namespace (@global)**: No login required to download +- 🔒 **Team namespaces**: Authentication required to download +- 📍 Suitable for organization-wide, publicly shareable skills + +### NAMESPACE_ONLY +- ✅ Namespace members can search and view +- 🔒 Login required and must be namespace member to download +- 📍 Suitable for team-internal skills + +### PRIVATE +- ✅ Only owner can view +- 🔒 Login required and must be owner to download +- 📍 Suitable for skills under personal development + +**Important Notes**: +- Global namespace (`@global`) PUBLIC skills support anonymous downloads for wide distribution within the organization +- All team namespace skills (including PUBLIC) require authentication to ensure team boundary security + +## Canonical Slug Mapping + +SkillHub internally uses `@{namespace}/{skill}` format, but the compatibility layer automatically converts to ClawHub-style canonical slugs: + +| SkillHub Internal | Canonical Slug | Description | +|-------------------|----------------|-------------| +| `@global/my-skill` | `my-skill` | Global namespace skill | +| `@my-team/my-skill` | `my-team--my-skill` | Team namespace skill | + +OpenClaw CLI uses canonical slug format, and SkillHub handles the conversion automatically. + +## Configuration Examples + +### ClawHub CLI Environment Variables + +ClawHub CLI is configured via environment variables: + +```bash +# Registry configuration +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +export CLAWHUB_API_TOKEN=sk_your_api_token_here +``` + +### Environment Variables + +```bash +# Registry configuration +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +export CLAWHUB_API_TOKEN=sk_your_api_token_here + +# Optional: Skip SSL verification (development only) +export CLAWHUB_SKIP_SSL_VERIFY=false +``` + +## FAQ + +### Q: How do I switch back to public ClawHub? + +```bash +# Unset custom registry +unset CLAWHUB_REGISTRY_URL + +# ClawHub CLI will use the default public registry +``` + +### Q: Getting 403 Forbidden when downloading? + +Possible causes: +1. Skill belongs to a team namespace, authentication required +2. Skill is NAMESPACE_ONLY or PRIVATE, authentication required +3. You're not a member of the namespace +4. API Token has expired + +Solution: +```bash +# Set new token +export CLAWHUB_API_TOKEN=YOUR_NEW_TOKEN + +# Test connection +curl https://skillhub.your-company.com/api/v1/whoami \ + -H "Authorization: Bearer $CLAWHUB_API_TOKEN" +``` + +**Tip**: Global namespace (@global) PUBLIC skills can be downloaded anonymously without authentication. + +### Q: How do I see all skills I have access to? + +```bash +# Search all skills (filtered by permissions) +npx clawhub search "" +``` + +### Q: Permission denied when publishing? + +- Publishing to global namespace (`@global`) requires `SUPER_ADMIN` permission +- Publishing to team namespace requires OWNER or ADMIN role in that namespace +- Contact your administrator for appropriate permissions + +### Q: Which OpenClaw versions are supported? + +SkillHub compatibility layer is designed to work with tools using ClawHub CLI. ClawHub CLI is distributed via npm: + +```bash +# Install ClawHub CLI +npm install -g clawhub + +# Or use npx directly +npx clawhub install my-skill +``` + +If you encounter compatibility issues, please file an issue. + +## API Response Formats + +### Search Response Example + +```json +{ + "results": [ + { + "slug": "my-team--email-sender", + "name": "Email Sender", + "description": "Send emails via SMTP", + "author": { + "handle": "user123", + "displayName": "John Doe" + }, + "version": "1.2.0", + "downloadCount": 150, + "starCount": 25, + "createdAt": "2026-01-15T10:00:00Z", + "updatedAt": "2026-03-10T14:30:00Z" + } + ], + "total": 1, + "page": 1, + "limit": 20 +} +``` + +### Version Resolution Response Example + +```json +{ + "slug": "my-skill", + "version": "1.2.0", + "downloadUrl": "/api/v1/skills/global/my-skill/versions/1.2.0/download" +} +``` + +### Publish Response Example + +```json +{ + "id": "12345", + "version": { + "id": "67890" + } +} +``` + +## Security Recommendations + +1. **Use HTTPS**: Always use HTTPS in production +2. **Token Management**: + - Rotate API tokens regularly + - Never hardcode tokens in code + - Use environment variables or secret management tools +3. **Least Privilege**: Assign minimum required permissions to tokens +4. **Audit Logs**: Regularly review SkillHub audit logs + +## Troubleshooting + +### Enable Debug Logging + +```bash +# View detailed request logs +DEBUG=clawhub:* npx clawhub search my-skill + +# Or use verbose mode +npx clawhub --verbose install my-skill +``` + +### Test Connection + +```bash +# Test registry connection +curl https://skillhub.your-company.com/api/v1/whoami \ + -H "Authorization: Bearer YOUR_TOKEN" + +# Test search +curl "https://skillhub.your-company.com/api/v1/search?q=test" +``` + +## Further Reading + +- [SkillHub API Design](./06-api-design.md) +- [Skill Protocol Specification](./07-skill-protocol.md) +- [Authentication & Authorization](./03-authentication-design.md) +- [Deployment Guide](./09-deployment.md) + +## Support + +For questions or suggestions: +- 📖 Full Documentation: https://zread.ai/iflytek/skillhub +- 💬 GitHub Discussions: https://github.com/iflytek/skillhub/discussions +- 🐛 Submit Issues: https://github.com/iflytek/skillhub/issues diff --git a/docs/openclaw-integration.md b/docs/openclaw-integration.md new file mode 100644 index 00000000..d29b88a8 --- /dev/null +++ b/docs/openclaw-integration.md @@ -0,0 +1,307 @@ +# OpenClaw 集成指南 + +本文档说明如何配置 OpenClaw CLI 连接到 SkillHub 私有注册中心,实现技能的发布、搜索和下载。 + +## 概述 + +SkillHub 提供了与 ClawHub 兼容的 API 层,使得 OpenClaw CLI 可以无缝对接私有注册中心。通过简单的配置,您可以: + +- 🔍 搜索组织内的私有技能 +- 📥 下载和安装技能包 +- 📤 发布新技能到私有注册中心 +- ⭐ 收藏和评分技能 + +## 快速开始 + +### 1. 配置 Registry 地址 + +在 OpenClaw 配置文件中设置 SkillHub 注册中心地址: + +```bash +# 通过环境变量配置 +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +``` + +### 2. 登录认证(可选) + +对于**全局命名空间(@global)的公开技能(PUBLIC)**,无需登录即可下载。对于以下情况需要认证: + +- 团队命名空间的技能(无论可见性) +- NAMESPACE_ONLY 或 PRIVATE 技能 +- 发布、收藏等写操作 + +```bash +# 使用 API Token +export CLAWHUB_API_TOKEN=YOUR_API_TOKEN +``` + +#### 获取 API Token + +1. 登录 SkillHub Web UI +2. 进入 **个人设置 → API Tokens** +3. 点击 **创建新 Token** +4. 设置 Token 名称和权限范围 +5. 复制生成的 Token + +### 3. 搜索技能 + +```bash +# 搜索技能 +npx clawhub search email + +# 查看技能详情 +npx clawhub info my-skill +``` + +### 4. 安装技能 + +```bash +# 安装最新版本 +npx clawhub install my-skill + +# 安装指定版本 +npx clawhub install my-skill@1.2.0 + +# 安装团队命名空间下的技能 +npx clawhub install my-namespace--my-skill +``` + +### 5. 发布技能 + +```bash +# 发布技能(需要相应权限) +npx clawhub publish ./my-skill +``` + +## API 端点说明 + +SkillHub 兼容层提供以下端点: + +| 端点 | 方法 | 说明 | 认证要求 | +|------|------|------|----------| +| `/api/v1/whoami` | GET | 获取当前用户信息 | 必需 | +| `/api/v1/search` | GET | 搜索技能 | 可选 | +| `/api/v1/resolve` | GET | 解析技能版本 | 可选 | +| `/api/v1/download/{slug}` | GET | 下载技能(重定向) | 可选* | +| `/api/v1/download` | GET | 下载技能(查询参数) | 可选* | +| `/api/v1/skills/{slug}` | GET | 获取技能详情 | 可选 | +| `/api/v1/skills/{slug}/star` | POST | 收藏技能 | 必需 | +| `/api/v1/skills/{slug}/unstar` | DELETE | 取消收藏 | 必需 | +| `/api/v1/publish` | POST | 发布技能 | 必需 | + +\* 下载端点认证要求: +- **全局命名空间(@global)的 PUBLIC 技能**:无需认证 +- **团队命名空间的所有技能**:需要认证 +- **NAMESPACE_ONLY 和 PRIVATE 技能**:需要认证 + +## 技能可见性说明 + +SkillHub 支持三种技能可见性级别,下载权限规则如下: + +### PUBLIC(公开) +- ✅ 任何人都可以搜索和查看 +- ✅ **全局命名空间(@global)**:无需登录即可下载 +- 🔒 **团队命名空间**:需要登录认证才能下载 +- 📍 适用于组织内通用的、可公开分享的技能 + +### NAMESPACE_ONLY(命名空间内可见) +- ✅ 命名空间成员可以搜索和查看 +- 🔒 需要登录且是命名空间成员才能下载 +- 📍 适用于团队内部技能 + +### PRIVATE(私有) +- ✅ 仅所有者可以查看 +- 🔒 需要登录且是所有者才能下载 +- 📍 适用于个人开发中的技能 + +**重要说明**: +- 全局命名空间(`@global`)的 PUBLIC 技能支持匿名下载,便于组织内广泛分发 +- 团队命名空间的所有技能(包括 PUBLIC)都需要认证,确保团队边界安全 + +## Canonical Slug 映射规则 + +SkillHub 内部使用 `@{namespace}/{skill}` 格式,但兼容层会自动转换为 ClawHub 风格的 canonical slug: + +| SkillHub 内部坐标 | Canonical Slug | 说明 | +|-------------------|----------------|------| +| `@global/my-skill` | `my-skill` | 全局命名空间技能 | +| `@my-team/my-skill` | `my-team--my-skill` | 团队命名空间技能 | + +OpenClaw CLI 使用 canonical slug 格式,SkillHub 会自动处理转换。 + +## 配置示例 + +### ClawHub CLI 环境变量配置 + +ClawHub CLI 通过环境变量配置: + +```bash +# Registry 配置 +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +export CLAWHUB_API_TOKEN=sk_your_api_token_here +``` + +### 环境变量配置 + +```bash +# Registry 配置 +export CLAWHUB_REGISTRY_URL=https://skillhub.your-company.com +export CLAWHUB_API_TOKEN=sk_your_api_token_here + +# 可选:跳过 SSL 验证(仅用于开发环境) +export CLAWHUB_SKIP_SSL_VERIFY=false +``` + +## 常见问题 + +### Q: 如何切换回公共 ClawHub? + +```bash +# 取消设置自定义 Registry +unset CLAWHUB_REGISTRY_URL + +# ClawHub CLI 将使用默认的公共注册中心 +``` + +### Q: 下载技能时提示 403 Forbidden? + +可能原因: +1. 技能属于团队命名空间,需要登录 +2. 技能是 NAMESPACE_ONLY 或 PRIVATE,需要登录 +3. 您不是该命名空间的成员 +4. API Token 已过期 + +解决方法: +```bash +# 设置新的 Token +export CLAWHUB_API_TOKEN=YOUR_NEW_TOKEN + +# 测试连接 +curl https://skillhub.your-company.com/api/v1/whoami \ + -H "Authorization: Bearer $CLAWHUB_API_TOKEN" +``` + +**提示**:全局命名空间(@global)的 PUBLIC 技能可以匿名下载,无需认证。 + +### Q: 如何查看我有权访问的所有技能? + +```bash +# 搜索所有技能(会根据权限过滤) +npx clawhub search "" +``` + +### Q: 发布技能时提示权限不足? + +- 发布到全局命名空间(`@global`)需要 `SUPER_ADMIN` 权限 +- 发布到团队命名空间需要是该命名空间的 OWNER 或 ADMIN +- 联系管理员分配相应权限 + +### Q: 支持哪些 OpenClaw 版本? + +SkillHub 兼容层设计兼容使用 ClawHub CLI 的工具。ClawHub CLI 通过 npm 分发: + +```bash +# 安装 ClawHub CLI +npm install -g clawhub + +# 或使用 npx 直接运行 +npx clawhub install my-skill +``` + +如遇到兼容性问题,请提交 Issue。 + +## API 响应格式 + +### 搜索响应示例 + +```json +{ + "results": [ + { + "slug": "my-team--email-sender", + "name": "Email Sender", + "description": "Send emails via SMTP", + "author": { + "handle": "user123", + "displayName": "John Doe" + }, + "version": "1.2.0", + "downloadCount": 150, + "starCount": 25, + "createdAt": "2026-01-15T10:00:00Z", + "updatedAt": "2026-03-10T14:30:00Z" + } + ], + "total": 1, + "page": 1, + "limit": 20 +} +``` + +### 版本解析响应示例 + +```json +{ + "slug": "my-skill", + "version": "1.2.0", + "downloadUrl": "/api/v1/skills/global/my-skill/versions/1.2.0/download" +} +``` + +### 发布响应示例 + +```json +{ + "id": "12345", + "version": { + "id": "67890" + } +} +``` + +## 安全建议 + +1. **使用 HTTPS**:生产环境务必使用 HTTPS 连接 +2. **Token 管理**: + - 定期轮换 API Token + - 不要在代码中硬编码 Token + - 使用环境变量或密钥管理工具 +3. **权限最小化**:为 Token 分配最小必需权限 +4. **审计日志**:定期检查 SkillHub 审计日志 + +## 故障排查 + +### 启用调试日志 + +```bash +# 查看详细请求日志 +DEBUG=clawhub:* npx clawhub search my-skill + +# 或使用 verbose 模式 +npx clawhub --verbose install my-skill +``` + +### 测试连接 + +```bash +# 测试 Registry 连接 +curl https://skillhub.your-company.com/api/v1/whoami \ + -H "Authorization: Bearer YOUR_TOKEN" + +# 测试搜索 +curl "https://skillhub.your-company.com/api/v1/search?q=test" +``` + +## 进一步阅读 + +- [SkillHub API 设计文档](./06-api-design.md) +- [技能协议规范](./07-skill-protocol.md) +- [认证与授权](./03-authentication-design.md) +- [部署指南](./09-deployment.md) + +## 支持 + +如有问题或建议: +- 📖 查看完整文档:https://zread.ai/iflytek/skillhub +- 💬 GitHub Discussions:https://github.com/iflytek/skillhub/discussions +- 🐛 提交 Issue:https://github.com/iflytek/skillhub/issues diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java index 87362d7c..369155a9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java @@ -10,6 +10,7 @@ import com.iflytek.skillhub.TestRedisConfig; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import java.io.ByteArrayInputStream; @@ -99,13 +100,29 @@ void downloadVersion_streamsWhenPresignedUrlUnavailable() throws Exception { } @Test - void downloadVersion_requiresAuthentication() throws Exception { + void downloadVersion_allowsAnonymousForGlobalSkill() throws Exception { + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, java.util.Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + new ByteArrayInputStream("zip".getBytes()), + "demo-skill-1.0.0.zip", + 3L, + "application/zip", + null + )); + mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download") .with(csrf())) - .andDo(result -> { - System.out.println("Status: " + result.getResponse().getStatus()); - System.out.println("Body: " + result.getResponse().getContentAsString()); - }) - .andExpect(status().isUnauthorized()); + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", "attachment; filename=\"demo-skill-1.0.0.zip\"")); + } + + @Test + void downloadVersion_forbidsAnonymousWhenServiceRejectsSkill() throws Exception { + given(skillDownloadService.downloadVersion("team-ai", "demo-skill", "1.0.0", null, java.util.Map.of())) + .willThrow(new DomainForbiddenException("error.skill.access.denied", "demo-skill")); + + mockMvc.perform(get("/api/v1/skills/team-ai/demo-skill/versions/1.0.0/download") + .with(csrf())) + .andExpect(status().isForbidden()); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 0208e7b1..664e9ba4 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -135,20 +135,26 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/skills/*/*", "/api/v1/skills/*/*/versions", "/api/v1/skills/*/*/versions/*", + "/api/v1/skills/*/*/download", + "/api/v1/skills/*/*/versions/*/download", "/api/v1/skills/*/*/versions/*/files", "/api/v1/skills/*/*/versions/*/file", "/api/v1/skills/*/*/resolve", "/api/v1/skills/*/*/tags", + "/api/v1/skills/*/*/tags/*/download", "/api/v1/skills/*/*/tags/*/files", "/api/v1/skills/*/*/tags/*/file", "/api/web/skills", "/api/web/skills/*/*", "/api/web/skills/*/*/versions", "/api/web/skills/*/*/versions/*", + "/api/web/skills/*/*/download", + "/api/web/skills/*/*/versions/*/download", "/api/web/skills/*/*/versions/*/files", "/api/web/skills/*/*/versions/*/file", "/api/web/skills/*/*/resolve", "/api/web/skills/*/*/tags", + "/api/web/skills/*/*/tags/*/download", "/api/web/skills/*/*/tags/*/files", "/api/web/skills/*/*/tags/*/file" ).permitAll() diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 9517905b..7f26b8a7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; @@ -72,11 +73,7 @@ public DownloadResult downloadLatest( Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - - // Visibility check - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { - throw new DomainForbiddenException("error.skill.access.denied", skillSlug); - } + assertCanDownload(namespace, skill, currentUserId, userNsRoles); if (skill.getLatestVersionId() == null) { throw new DomainBadRequestException("error.skill.version.latest.unavailable", skillSlug); @@ -97,11 +94,7 @@ public DownloadResult downloadVersion( Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - - // Visibility check - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { - throw new DomainForbiddenException("error.skill.access.denied", skillSlug); - } + assertCanDownload(namespace, skill, currentUserId, userNsRoles); SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), versionStr) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionStr)); @@ -118,11 +111,7 @@ public DownloadResult downloadByTag( Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - - // Visibility check - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { - throw new DomainForbiddenException("error.skill.access.denied", skillSlug); - } + assertCanDownload(namespace, skill, currentUserId, userNsRoles); SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName) .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tagName)); @@ -217,6 +206,23 @@ private Namespace findNamespace(String slug) { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } + private void assertCanDownload(Namespace namespace, + Skill skill, + String currentUserId, + Map userNsRoles) { + if (currentUserId == null && !isAnonymousDownloadAllowed(namespace, skill)) { + throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); + } + if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); + } + } + + private boolean isAnonymousDownloadAllowed(Namespace namespace, Skill skill) { + return namespace.getType() == NamespaceType.GLOBAL + && skill.getVisibility() == SkillVisibility.PUBLIC; + } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { return skillSlugResolutionService.resolve( namespaceId, diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index b4af6d98..0b407ded 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -4,7 +4,9 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.storage.ObjectMetadata; import com.iflytek.skillhub.storage.ObjectStorageService; @@ -267,6 +269,58 @@ void testDownloadVersion_ShouldFallbackToBundledFilesWhenBundleIsMissing() throw verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadVersion_AllowsAnonymousForGlobalPublicSkill() throws Exception { + Namespace namespace = new Namespace("global", "Global", "system"); + setId(namespace, 1L); + namespace.setType(NamespaceType.GLOBAL); + + Skill skill = new Skill(1L, "demo-skill", "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setDisplayName("Demo Skill"); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(version)); + when(objectStorageService.exists("packages/1/10/bundle.zip")).thenReturn(false); + when(skillFileRepository.findByVersionId(10L)).thenReturn(List.of( + new SkillFile(10L, "SKILL.md", 4L, "text/markdown", "hash", "skills/1/10/SKILL.md"))); + when(objectStorageService.exists("skills/1/10/SKILL.md")).thenReturn(true); + when(objectStorageService.getObject("skills/1/10/SKILL.md")).thenReturn(new ByteArrayInputStream("test".getBytes())); + + SkillDownloadService.DownloadResult result = service.downloadVersion("global", "demo-skill", "1.0.0", null, Map.of()); + + assertNotNull(result); + assertEquals("Demo Skill-1.0.0.zip", result.filename()); + } + + @Test + void testDownloadVersion_RejectsAnonymousForTeamNamespacePublicSkill() throws Exception { + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + setId(namespace, 2L); + namespace.setType(NamespaceType.TEAM); + + Skill skill = new Skill(2L, "demo-skill", "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(2L, "demo-skill")).thenReturn(List.of(skill)); + + assertThrows(DomainForbiddenException.class, () -> + service.downloadVersion("team-ai", "demo-skill", "1.0.0", null, Map.of())); + + verify(visibilityChecker, never()).canAccess(any(), any(), anyMap()); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true);