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 static org.springframework.test.web.servlet.result.MockMvcResultMatchers. 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 @@ class SkillControllerDownloadTest { } @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 class SecurityConfig { "/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.event.SkillDownloadedEvent; 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 class SkillDownloadService { 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 class SkillDownloadService { 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 class SkillDownloadService { 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 @@ public class SkillDownloadService { .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.event.SkillDownloadedEvent; 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 @@ class SkillDownloadServiceTest { 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);