Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ services:
redis:
image: ${REDIS_IMAGE:-redis:7-alpine}
ports:
- "6379:6379"
- "127.0.0.1:6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ public ClawHubResolveResponse resolveByQuery(String slug,
String hash,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
SkillCoordinate coord = resolveQueryCoordinate(slug);
SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles);
Map<Long, NamespaceRole> roles = normalizeRoles(userNsRoles);

SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
coord.namespace(),
Expand All @@ -103,7 +104,7 @@ public ClawHubResolveResponse resolveByQuery(String slug,
"latest".equals(version) ? "latest" : null,
hash,
userId,
userNsRoles != null ? userNsRoles : Map.of()
roles
);
return toResolveResponse(resolved);
}
Expand Down Expand Up @@ -132,23 +133,37 @@ public String downloadLocationByPath(String canonicalSlug, String version) {
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
}

public String downloadLocationByQuery(String slug, String version) {
SkillCoordinate coord = resolveQueryCoordinate(slug);
public String downloadLocationByQuery(String slug,
String version,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles);
return "latest".equals(version)
? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download"
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
}

private SkillCoordinate resolveQueryCoordinate(String slug) {
private SkillCoordinate resolveQueryCoordinate(String slug,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
if (slug != null && slug.contains("--")) {
return mapper.fromCanonical(slug);
}
CompatSkillLookupService.CompatSkillContext context;
try {
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug);
return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug());
context = compatSkillLookupService.findByLegacySlug(slug);
} catch (DomainNotFoundException ex) {
return mapper.fromCanonical(slug);
}
Map<Long, NamespaceRole> roles = normalizeRoles(userNsRoles);
if (!compatSkillLookupService.canAccess(context.skill(), userId, roles)) {
throw new DomainNotFoundException("error.skill.notFound", slug);
}
return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug());
}

private Map<Long, NamespaceRole> normalizeRoles(Map<Long, NamespaceRole> userNsRoles) {
return userNsRoles != null ? userNsRoles : Map.of();
}

public ClawHubSkillListResponse listSkills(int page,
Expand Down Expand Up @@ -182,11 +197,18 @@ public ClawHubSkillListResponse listSkills(int page,
}

public ClawHubSkillResponse getSkill(String canonicalSlug, String userId) {
return getSkill(canonicalSlug, userId, Map.of());
}

public ClawHubSkillResponse getSkill(String canonicalSlug,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible(
coord.namespace(),
coord.slug(),
userId
userId,
userNsRoles != null ? userNsRoles : Map.of()
);
SkillVersion latestVersionEntity = context.latestVersion().orElse(null);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,10 @@ public ResponseEntity<Void> downloadByPath(@PathVariable String canonicalSlug,
@RateLimit(category = "download", authenticated = 60, anonymous = 20)
@GetMapping("/download")
public ResponseEntity<Void> downloadByQuery(@RequestParam String slug,
@RequestParam(defaultValue = "latest") String version) {
return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version));
@RequestParam(defaultValue = "latest") String version,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version, userId, userNsRoles));
}

@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
Expand All @@ -99,8 +101,9 @@ public ClawHubSkillListResponse listSkills(@RequestParam(defaultValue = "0") int
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
@GetMapping("/skills/{canonicalSlug}")
public ClawHubSkillResponse getSkill(@PathVariable String canonicalSlug,
@RequestAttribute(value = "userId", required = false) String userId) {
return clawHubCompatAppService.getSkill(canonicalSlug, userId);
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return clawHubCompatAppService.getSkill(canonicalSlug, userId, userNsRoles);
}

@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ public ClawHubRegistrySkillResponse getSkill(
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible(
coordinate.namespace(),
coordinate.slug(),
userId
userId,
normalizeRoles(userNsRoles)
);
Skill skill = context.skill();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package com.iflytek.skillhub.compat;

import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Service;

Expand All @@ -22,15 +25,18 @@ public class CompatSkillLookupService {
private final NamespaceRepository namespaceRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillSlugResolutionService skillSlugResolutionService;
private final VisibilityChecker visibilityChecker;

public CompatSkillLookupService(SkillRepository skillRepository,
NamespaceRepository namespaceRepository,
SkillVersionRepository skillVersionRepository,
SkillSlugResolutionService skillSlugResolutionService) {
SkillSlugResolutionService skillSlugResolutionService,
VisibilityChecker visibilityChecker) {
this.skillRepository = skillRepository;
this.namespaceRepository = namespaceRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillSlugResolutionService = skillSlugResolutionService;
this.visibilityChecker = visibilityChecker;
}

public CompatSkillContext findByLegacySlug(String slug) {
Expand All @@ -41,10 +47,28 @@ public CompatSkillContext findByLegacySlug(String slug) {
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
}

public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
if (skill == null) {
return false;
}
Map<Long, NamespaceRole> roles = userNsRoles != null ? userNsRoles : Map.of();
return visibilityChecker.canAccess(skill, currentUserId, roles);
}

public CompatSkillContext resolveVisible(String namespaceSlug, String skillSlug, String currentUserId) {
return resolveVisible(namespaceSlug, skillSlug, currentUserId, Map.of());
}

public CompatSkillContext resolveVisible(String namespaceSlug,
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", namespaceSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
if (!canAccess(skill, currentUserId, userNsRoles)) {
throw new DomainNotFoundException("error.skill.notFound", skillSlug);
}
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ public NamespaceController(NamespacePortalQueryAppService namespacePortalQueryAp
}

@GetMapping("/namespaces")
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(Pageable pageable) {
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable));
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(
Pageable pageable,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles));
}

@GetMapping("/me/namespaces")
Expand All @@ -68,7 +70,7 @@ public ApiResponse<List<MyNamespaceResponse>> listMyNamespaces(

@GetMapping("/namespaces/{slug}")
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read",
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.MemberResponse;
Expand All @@ -20,6 +21,8 @@
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down Expand Up @@ -50,9 +53,31 @@ public NamespacePortalQueryAppService(NamespaceRepository namespaceRepository,
}

@Transactional(readOnly = true)
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable) {
Page<Namespace> namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable);
return PageResponse.from(namespaces.map(NamespaceResponse::from));
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable, Map<Long, NamespaceRole> userNamespaceRoles) {
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
if (namespaceRoles.isEmpty()) {
Page<NamespaceResponse> empty = new PageImpl<>(
List.of(),
PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()),
0
);
return PageResponse.from(empty);
}

List<Namespace> scopedNamespaces = namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream()
.filter(namespace -> namespace.getStatus() == NamespaceStatus.ACTIVE)
.sorted(Comparator.comparing(Namespace::getSlug))
.toList();
int fromIndex = Math.min((int) pageable.getOffset(), scopedNamespaces.size());
int toIndex = Math.min(fromIndex + pageable.getPageSize(), scopedNamespaces.size());
Page<NamespaceResponse> page = new PageImpl<>(
scopedNamespaces.subList(fromIndex, toIndex).stream()
.map(NamespaceResponse::from)
.toList(),
pageable,
scopedNamespaces.size()
);
return PageResponse.from(page);
}

@Transactional(readOnly = true)
Expand All @@ -73,10 +98,14 @@ public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userN

@Transactional(readOnly = true)
public NamespaceResponse getNamespace(String slug, String userId, Map<Long, NamespaceRole> userNamespaceRoles) {
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
Namespace namespace = namespaceService.getNamespaceBySlugForRead(
slug,
userId,
userNamespaceRoles != null ? userNamespaceRoles : Map.of());
namespaceRoles);
if (!namespaceRoles.containsKey(namespace.getId())) {
throw new DomainForbiddenException("error.namespace.membership.required");
}
return NamespaceResponse.from(namespace);
}

Expand Down
4 changes: 2 additions & 2 deletions server/skillhub-app/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
include: health,info
endpoint:
health:
show-details: when-authorized
Expand All @@ -180,4 +180,4 @@ management:
application: skillhub
export:
prometheus:
enabled: true
enabled: false
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.iflytek.skillhub.compat;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.iflytek.skillhub.controller.support.MultipartPackageExtractor;
import com.iflytek.skillhub.controller.support.ZipPackageExtractor;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;

class ClawHubCompatAppServiceTest {

private final SkillSearchAppService skillSearchAppService = mock(SkillSearchAppService.class);
private final SkillQueryService skillQueryService = mock(SkillQueryService.class);
private final SkillPublishService skillPublishService = mock(SkillPublishService.class);
private final ZipPackageExtractor zipPackageExtractor = mock(ZipPackageExtractor.class);
private final MultipartPackageExtractor multipartPackageExtractor = mock(MultipartPackageExtractor.class);
private final AuditLogService auditLogService = mock(AuditLogService.class);
private final CompatSkillLookupService compatSkillLookupService = mock(CompatSkillLookupService.class);
private final SkillStarService skillStarService = mock(SkillStarService.class);

private final ClawHubCompatAppService service = new ClawHubCompatAppService(
new CanonicalSlugMapper(),
skillSearchAppService,
skillQueryService,
skillPublishService,
zipPackageExtractor,
multipartPackageExtractor,
auditLogService,
compatSkillLookupService,
skillStarService
);

@Test
void downloadLocationByQuery_throwsNotFound_whenLegacySkillIsPrivateForAnonymousCaller() {
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE);
CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext(
namespace,
privateSkill,
Optional.empty()
);

when(compatSkillLookupService.findByLegacySlug("priv")).thenReturn(context);
when(compatSkillLookupService.canAccess(privateSkill, null, Map.of())).thenReturn(false);

assertThatThrownBy(() -> service.downloadLocationByQuery("priv", "latest", null, null))
.isInstanceOf(DomainNotFoundException.class);
}

@Test
void downloadLocationByQuery_returnsCanonicalPath_whenLegacySkillIsVisible() {
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
Skill publicSkill = new Skill(1L, "my-skill", "owner-1", SkillVisibility.PUBLIC);
CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext(
namespace,
publicSkill,
Optional.empty()
);

when(compatSkillLookupService.findByLegacySlug("my-skill")).thenReturn(context);
when(compatSkillLookupService.canAccess(publicSkill, null, Map.of())).thenReturn(true);

String location = service.downloadLocationByQuery("my-skill", "latest", null, null);

assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/download");
}
}
Loading
Loading