Allow anonymous downloads for global public skills

This commit is contained in:
vsxd 2026-03-17 20:25:26 +08:00
parent cc5fc7586f
commit 72054dee0f
4 changed files with 104 additions and 21 deletions

View file

@ -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());
}
}

View file

@ -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()

View file

@ -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<Long, NamespaceRole> 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,

View file

@ -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);