diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java index 668a3b44..92705731 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java @@ -15,8 +15,15 @@ public class DomainBeanConfig { } @Bean - public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser) { - return new SkillPackageValidator(skillMetadataParser); + public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser, + SkillPublishProperties skillPublishProperties) { + return new SkillPackageValidator( + skillMetadataParser, + skillPublishProperties.getMaxFileCount(), + skillPublishProperties.getMaxSingleFileSize(), + skillPublishProperties.getMaxPackageSize(), + skillPublishProperties.getAllowedFileExtensions() + ); } @Bean diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java new file mode 100644 index 00000000..7d5fadd7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashSet; +import java.util.Set; + +@Component +@ConfigurationProperties(prefix = "skillhub.publish") +public class SkillPublishProperties { + + private int maxFileCount = 100; + private long maxSingleFileSize = 1024 * 1024; + private long maxPackageSize = 100 * 1024 * 1024; + private Set allowedFileExtensions = new LinkedHashSet<>(Set.of( + ".md", ".txt", ".json", ".yaml", ".yml", + ".js", ".ts", ".py", ".sh", + ".png", ".jpg", ".svg" + )); + + public int getMaxFileCount() { + return maxFileCount; + } + + public void setMaxFileCount(int maxFileCount) { + this.maxFileCount = maxFileCount; + } + + public long getMaxSingleFileSize() { + return maxSingleFileSize; + } + + public void setMaxSingleFileSize(long maxSingleFileSize) { + this.maxSingleFileSize = maxSingleFileSize; + } + + public long getMaxPackageSize() { + return maxPackageSize; + } + + public void setMaxPackageSize(long maxPackageSize) { + this.maxPackageSize = maxPackageSize; + } + + public Set getAllowedFileExtensions() { + return allowedFileExtensions; + } + + public void setAllowedFileExtensions(Set allowedFileExtensions) { + this.allowedFileExtensions = new LinkedHashSet<>(allowedFileExtensions); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java index 76bae215..c27ee079 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller.cli; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.controller.support.ZipPackageExtractor; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; @@ -12,21 +13,21 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.util.ArrayList; import java.util.List; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; @RestController @RequestMapping("/api/v1/cli") public class CliPublishController extends BaseApiController { private final SkillPublishService skillPublishService; + private final ZipPackageExtractor zipPackageExtractor; public CliPublishController(SkillPublishService skillPublishService, + ZipPackageExtractor zipPackageExtractor, ApiResponseFactory responseFactory) { super(responseFactory); this.skillPublishService = skillPublishService; + this.zipPackageExtractor = zipPackageExtractor; } @PostMapping("/publish") @@ -39,7 +40,7 @@ public class CliPublishController extends BaseApiController { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); - List entries = extractZipEntries(file); + List entries = zipPackageExtractor.extract(file); SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( namespace, @@ -60,35 +61,4 @@ public class CliPublishController extends BaseApiController { return ok("response.success.published", response); } - - private List extractZipEntries(MultipartFile file) throws IOException { - List entries = new ArrayList<>(); - - try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { - ZipEntry zipEntry; - while ((zipEntry = zis.getNextEntry()) != null) { - if (!zipEntry.isDirectory()) { - byte[] content = zis.readAllBytes(); - entries.add(new PackageEntry( - zipEntry.getName(), - content, - content.length, - determineContentType(zipEntry.getName()) - )); - } - zis.closeEntry(); - } - } - - return entries; - } - - private String determineContentType(String filename) { - if (filename.endsWith(".py")) return "text/x-python"; - if (filename.endsWith(".json")) return "application/json"; - if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; - if (filename.endsWith(".txt")) return "text/plain"; - if (filename.endsWith(".md")) return "text/markdown"; - return "application/octet-stream"; - } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index d65bf9f9..eb074de5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java @@ -4,10 +4,13 @@ import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.controller.BaseApiController; 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.review.PromotionRequest; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.PromotionService; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +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; @@ -19,6 +22,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.web.bind.annotation.*; +import java.util.Map; import java.util.Set; @RestController @@ -54,10 +58,13 @@ public class PromotionController extends BaseApiController { @PostMapping public ApiResponse submitPromotion( @RequestBody PromotionRequestDto request, - @RequestAttribute("userId") String userId) { + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { PromotionRequest promotion = promotionService.submitPromotion( request.sourceSkillId(), request.sourceVersionId(), - request.targetNamespaceId(), userId); + request.targetNamespaceId(), userId, + userNsRoles != null ? userNsRoles : Map.of(), + rbacService.getUserRoleCodes(userId)); return ok("response.success.created", toResponse(promotion)); } @@ -91,7 +98,7 @@ public class PromotionController extends BaseApiController { Set platformRoles = rbacService.getUserRoleCodes(userId); boolean hasAdminRole = platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); if (!hasAdminRole) { - return ok("response.success.read", PageResponse.from(Page.empty())); + throw new DomainForbiddenException("promotion.no_permission"); } Page requests = promotionRequestRepository.findByStatus( ReviewTaskStatus.PENDING, PageRequest.of(page, size)); @@ -99,16 +106,25 @@ public class PromotionController extends BaseApiController { } @GetMapping("/{id}") - public ApiResponse getPromotionDetail(@PathVariable Long id) { - PromotionRequest promotion = promotionRequestRepository.findById(id).orElseThrow(); + public ApiResponse getPromotionDetail(@PathVariable Long id, + @RequestAttribute("userId") String userId) { + PromotionRequest promotion = promotionRequestRepository.findById(id) + .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", id)); + if (!promotionService.canViewPromotion(promotion, userId, rbacService.getUserRoleCodes(userId))) { + throw new DomainForbiddenException("promotion.no_permission"); + } return ok("response.success.read", toResponse(promotion)); } private PromotionResponseDto toResponse(PromotionRequest req) { - Skill sourceSkill = skillRepository.findById(req.getSourceSkillId()).orElseThrow(); - SkillVersion sourceVersion = skillVersionRepository.findById(req.getSourceVersionId()).orElseThrow(); - Namespace sourceNs = namespaceRepository.findById(sourceSkill.getNamespaceId()).orElseThrow(); - Namespace targetNs = namespaceRepository.findById(req.getTargetNamespaceId()).orElseThrow(); + Skill sourceSkill = skillRepository.findById(req.getSourceSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", req.getSourceSkillId())); + SkillVersion sourceVersion = skillVersionRepository.findById(req.getSourceVersionId()) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", req.getSourceVersionId())); + Namespace sourceNs = namespaceRepository.findById(sourceSkill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); + Namespace targetNs = namespaceRepository.findById(req.getTargetNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", req.getTargetNamespaceId())); String submittedByName = userAccountRepository.findById(req.getSubmittedBy()) .map(UserAccount::getDisplayName).orElse(null); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java index 781962a0..36429752 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java @@ -9,6 +9,8 @@ import com.iflytek.skillhub.domain.review.ReviewService; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +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; @@ -56,11 +58,14 @@ public class ReviewController extends BaseApiController { @PostMapping public ApiResponse submitReview( @RequestBody ReviewTaskRequest request, - @RequestAttribute("userId") String userId) { - SkillVersion sv = skillVersionRepository.findById(request.skillVersionId()) - .orElseThrow(); - Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow(); - ReviewTask task = reviewService.submitReview(request.skillVersionId(), skill.getNamespaceId(), userId); + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + ReviewTask task = reviewService.submitReview( + request.skillVersionId(), + userId, + userNsRoles != null ? userNsRoles : Map.of(), + rbacService.getUserRoleCodes(userId) + ); return ok("response.success.created", toResponse(task)); } @@ -104,7 +109,15 @@ public class ReviewController extends BaseApiController { @RequestParam Long namespaceId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") String userId) { + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + Namespace namespace = namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); + ReviewTask probe = new ReviewTask(0L, namespaceId, "probe"); + if (!reviewService.canReviewNamespace(probe, userId, namespace.getType(), + userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId))) { + throw new DomainForbiddenException("review.no_permission"); + } Page tasks = reviewTaskRepository.findByNamespaceIdAndStatus( namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); @@ -121,15 +134,27 @@ public class ReviewController extends BaseApiController { } @GetMapping("/{id}") - public ApiResponse getReviewDetail(@PathVariable Long id) { - ReviewTask task = reviewTaskRepository.findById(id).orElseThrow(); + public ApiResponse getReviewDetail(@PathVariable Long id, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + ReviewTask task = reviewTaskRepository.findById(id) + .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id)); + Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); + if (!reviewService.canViewReview(task, userId, namespace.getType(), + userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId))) { + throw new DomainForbiddenException("review.no_permission"); + } return ok("response.success.read", toResponse(task)); } private ReviewTaskResponse toResponse(ReviewTask task) { - SkillVersion sv = skillVersionRepository.findById(task.getSkillVersionId()).orElseThrow(); - Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow(); - Namespace ns = namespaceRepository.findById(skill.getNamespaceId()).orElseThrow(); + SkillVersion sv = skillVersionRepository.findById(task.getSkillVersionId()) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); + Skill skill = skillRepository.findById(sv.getSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sv.getSkillId())); + Namespace ns = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); String submittedByName = userAccountRepository.findById(task.getSubmittedBy()) .map(UserAccount::getDisplayName).orElse(null); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 2fba90df..429c2457 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -75,10 +75,16 @@ public class SkillController extends BaseApiController { @PathVariable String namespace, @PathVariable String slug, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { + @RequestParam(defaultValue = "20") int size, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { Page versions = skillQueryService.listVersions( - namespace, slug, PageRequest.of(page, size)); + namespace, + slug, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + PageRequest.of(page, size)); PageResponse response = PageResponse.from(versions.map(v -> new SkillVersionResponse( v.getId(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index e984e31e..7e927815 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.controller.support.ZipPackageExtractor; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; @@ -12,21 +13,21 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.util.ArrayList; import java.util.List; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; @RestController @RequestMapping("/api/v1/skills") public class SkillPublishController extends BaseApiController { private final SkillPublishService skillPublishService; + private final ZipPackageExtractor zipPackageExtractor; public SkillPublishController(SkillPublishService skillPublishService, + ZipPackageExtractor zipPackageExtractor, ApiResponseFactory responseFactory) { super(responseFactory); this.skillPublishService = skillPublishService; + this.zipPackageExtractor = zipPackageExtractor; } @PostMapping("/{namespace}/publish") @@ -39,7 +40,7 @@ public class SkillPublishController extends BaseApiController { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); - List entries = extractZipEntries(file); + List entries = zipPackageExtractor.extract(file); SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( namespace, @@ -60,35 +61,4 @@ public class SkillPublishController extends BaseApiController { return ok("response.success.published", response); } - - private List extractZipEntries(MultipartFile file) throws IOException { - List entries = new ArrayList<>(); - - try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { - ZipEntry zipEntry; - while ((zipEntry = zis.getNextEntry()) != null) { - if (!zipEntry.isDirectory()) { - byte[] content = zis.readAllBytes(); - entries.add(new PackageEntry( - zipEntry.getName(), - content, - content.length, - determineContentType(zipEntry.getName()) - )); - } - zis.closeEntry(); - } - } - - return entries; - } - - private String determineContentType(String filename) { - if (filename.endsWith(".py")) return "text/x-python"; - if (filename.endsWith(".json")) return "application/json"; - if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; - if (filename.endsWith(".txt")) return "text/plain"; - if (filename.endsWith(".md")) return "text/markdown"; - return "application/octet-stream"; - } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java index 1882dac8..eab43d3f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.SkillTag; import com.iflytek.skillhub.domain.skill.service.SkillTagService; import com.iflytek.skillhub.dto.ApiResponse; @@ -12,6 +13,7 @@ import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @RestController @@ -29,9 +31,16 @@ public class SkillTagController extends BaseApiController { @GetMapping public ApiResponse> listTags( @PathVariable String namespace, - @PathVariable String slug) { + @PathVariable String slug, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - List tags = skillTagService.listTags(namespace, slug); + List tags = skillTagService.listTags( + namespace, + slug, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); List response = tags.stream() .map(TagResponse::from) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java new file mode 100644 index 00000000..f9791115 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -0,0 +1,127 @@ +package com.iflytek.skillhub.controller.support; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +@Component +public class ZipPackageExtractor { + + private static final int BUFFER_SIZE = 8192; + + private final SkillPublishProperties properties; + + public ZipPackageExtractor(SkillPublishProperties properties) { + this.properties = properties; + } + + public List extract(MultipartFile file) throws IOException { + List entries = new ArrayList<>(); + Set seenPaths = new HashSet<>(); + long totalSize = 0L; + + try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { + ZipEntry zipEntry; + while ((zipEntry = zis.getNextEntry()) != null) { + if (zipEntry.isDirectory()) { + zis.closeEntry(); + continue; + } + + if (entries.size() >= properties.getMaxFileCount()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Too many files: max " + properties.getMaxFileCount()); + } + + String normalizedPath = normalizeEntryPath(zipEntry.getName()); + if (!seenPaths.add(normalizedPath)) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Duplicate package path: " + normalizedPath); + } + + byte[] content = readEntry(zis, normalizedPath); + totalSize += content.length; + if (totalSize > properties.getMaxPackageSize()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Package too large: max " + properties.getMaxPackageSize() + " bytes"); + } + + entries.add(new PackageEntry( + normalizedPath, + content, + content.length, + determineContentType(normalizedPath) + )); + zis.closeEntry(); + } + } + + return entries; + } + + private byte[] readEntry(ZipInputStream zis, String path) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + long fileSize = 0L; + while ((read = zis.read(buffer)) != -1) { + fileSize += read; + if (fileSize > properties.getMaxSingleFileSize()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "File too large: " + path + " (max " + properties.getMaxSingleFileSize() + " bytes)"); + } + outputStream.write(buffer, 0, read); + } + return outputStream.toByteArray(); + } + + private String normalizeEntryPath(String path) { + if (path == null || path.isBlank()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", "Package entry path is blank"); + } + if (path.contains("\\")) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Package entry must use '/' separators: " + path); + } + + try { + Path normalized = Path.of(path).normalize(); + String normalizedPath = normalized.toString().replace('\\', '/'); + if (normalized.isAbsolute() + || normalizedPath.isBlank() + || normalizedPath.startsWith("../") + || normalizedPath.equals("..") + || path.startsWith("/") + || path.contains("//")) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Unsafe package path: " + path); + } + return normalizedPath; + } catch (InvalidPathException ex) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Invalid package path: " + path); + } + } + + private String determineContentType(String filename) { + if (filename.endsWith(".py")) return "text/x-python"; + if (filename.endsWith(".json")) return "application/json"; + if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; + if (filename.endsWith(".txt")) return "text/plain"; + if (filename.endsWith(".md")) return "text/markdown"; + return "application/octet-stream"; + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index a9e25284..5ece3012 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -53,13 +53,15 @@ skillhub: access-policy: mode: OPEN storage: - type: local + provider: local local: base-path: ${STORAGE_BASE_PATH:/tmp/skillhub-storage} search: engine: postgres rebuild-on-startup: false publish: + max-file-count: 100 + max-single-file-size: 1048576 # 1MB max-package-size: 104857600 # 100MB allowed-file-extensions: .py,.json,.yaml,.yml,.txt,.md,.sh 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 a8c94865..84fa1f23 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 @@ -75,7 +75,21 @@ public class SecurityConfig { "/api/compat/v1/search", "/api/compat/v1/resolve/**" ).permitAll() - .requestMatchers(HttpMethod.GET, "/api/v1/skills", "/api/v1/skills/**").permitAll() + .requestMatchers(HttpMethod.GET, + "/api/v1/skills", + "/api/v1/skills/*/*", + "/api/v1/skills/*/*/versions", + "/api/v1/skills/*/*/versions/*", + "/api/v1/skills/*/*/versions/*/files", + "/api/v1/skills/*/*/versions/*/file", + "/api/v1/skills/*/*/resolve", + "/api/v1/skills/*/*/download", + "/api/v1/skills/*/*/versions/*/download", + "/api/v1/skills/*/*/tags", + "/api/v1/skills/*/*/tags/*/files", + "/api/v1/skills/*/*/tags/*/file", + "/api/v1/skills/*/*/tags/*/download" + ).permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/namespaces", "/api/v1/namespaces/*").permitAll() .requestMatchers("/api/v1/admin/**").hasAnyRole("SUPER_ADMIN", "SKILL_ADMIN", "USER_ADMIN", "AUDITOR") .anyRequest().authenticated() diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java index 8007f29b..2e3db564 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java @@ -5,7 +5,10 @@ import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisOperations; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SessionCallback; +import org.springframework.data.redis.core.ValueOperations; import org.springframework.stereotype.Service; import java.security.SecureRandom; @@ -76,24 +79,48 @@ public class DeviceAuthService { } public DeviceTokenResponse pollToken(String deviceCode) { - DeviceCodeData data = readDeviceCodeData(deviceCode); + String key = DEVICE_CODE_PREFIX + deviceCode; + DeviceCodeData consumed = redisTemplate.execute(new SessionCallback<>() { + @Override + public DeviceCodeData execute(RedisOperations operations) { + while (true) { + operations.watch(key); + DeviceCodeData data = readDeviceCodeData(operations.opsForValue(), deviceCode); - if (data == null) { - throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid"); + if (data == null) { + operations.unwatch(); + throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid"); + } + + switch (data.getStatus()) { + case PENDING -> { + operations.unwatch(); + return null; + } + case USED -> { + operations.unwatch(); + throw new DomainBadRequestException("error.deviceAuth.deviceCode.used"); + } + case AUTHORIZED -> { + data.setStatus(DeviceCodeStatus.USED); + operations.multi(); + operations.opsForValue().set(key, data, 1, TimeUnit.MINUTES); + if (operations.exec() != null) { + return data; + } + } + } + } + } + }); + + if (consumed == null) { + return DeviceTokenResponse.pending(); } - return switch (data.getStatus()) { - case PENDING -> DeviceTokenResponse.pending(); - case AUTHORIZED -> { - data.setStatus(DeviceCodeStatus.USED); - redisTemplate.opsForValue().set( - DEVICE_CODE_PREFIX + deviceCode, data, 1, TimeUnit.MINUTES); - String token = apiTokenService.createToken( - data.getUserId(), "device-auth", "[]").rawToken(); - yield DeviceTokenResponse.success(token); - } - case USED -> throw new DomainBadRequestException("error.deviceAuth.deviceCode.used"); - }; + String token = apiTokenService.createToken( + consumed.getUserId(), "device-auth", "[]").rawToken(); + return DeviceTokenResponse.success(token); } private String generateRandomDeviceCode() { @@ -112,7 +139,11 @@ public class DeviceAuthService { } private DeviceCodeData readDeviceCodeData(String deviceCode) { - Object raw = redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); + return readDeviceCodeData(redisTemplate.opsForValue(), deviceCode); + } + + private DeviceCodeData readDeviceCodeData(ValueOperations valueOperations, String deviceCode) { + Object raw = valueOperations.get(DEVICE_CODE_PREFIX + deviceCode); if (raw == null) { return null; } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java index a8ada47d..e3118d65 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java @@ -9,6 +9,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.SessionCallback; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; @@ -29,6 +31,8 @@ class DeviceAuthServiceTest { @Mock private ValueOperations valueOperations; + @Mock + private RedisOperations redisOperations; @Mock private ApiTokenService apiTokenService; @@ -37,7 +41,10 @@ class DeviceAuthServiceTest { @BeforeEach void setUp() { - when(redisTemplate.opsForValue()).thenReturn(valueOperations); + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + lenient().when(redisOperations.opsForValue()).thenReturn(valueOperations); + lenient().when(redisTemplate.execute(any(SessionCallback.class))) + .thenAnswer(invocation -> invocation.>getArgument(0).execute(redisOperations)); service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device", new ObjectMapper()); } @@ -137,6 +144,7 @@ class DeviceAuthServiceTest { redisValue.put("status", "AUTHORIZED"); redisValue.put("userId", "42"); when(valueOperations.get("device:code:device123")).thenReturn(redisValue); + when(redisOperations.exec()).thenReturn(java.util.List.of("OK")); when(apiTokenService.createToken("42", "device-auth", "[]")) .thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null)); @@ -145,13 +153,17 @@ class DeviceAuthServiceTest { assertThat(response.error()).isNull(); assertThat(response.accessToken()).isEqualTo("sk_device_token"); assertThat(response.tokenType()).isEqualTo("Bearer"); + verify(redisOperations).watch("device:code:device123"); + verify(redisOperations).multi(); verify(valueOperations).set(eq("device:code:device123"), any(DeviceCodeData.class), eq(1L), eq(TimeUnit.MINUTES)); + verify(redisOperations).exec(); } @Test void pollToken_returns_access_token_when_authorized() { DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42"); when(valueOperations.get("device:code:device123")).thenReturn(data); + when(redisOperations.exec()).thenReturn(java.util.List.of("OK")); when(apiTokenService.createToken("42", "device-auth", "[]")) .thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null)); @@ -160,6 +172,9 @@ class DeviceAuthServiceTest { assertThat(response.error()).isNull(); assertThat(response.accessToken()).isEqualTo("sk_device_token"); assertThat(response.tokenType()).isEqualTo("Bearer"); + verify(redisOperations).watch("device:code:device123"); + verify(redisOperations).multi(); verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(1L), eq(TimeUnit.MINUTES)); + verify(redisOperations).exec(); } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index 006ba1bf..40aff667 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.review; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; 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; @@ -51,7 +52,9 @@ public class PromotionService { @Transactional public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId, - Long targetNamespaceId, String userId) { + Long targetNamespaceId, String userId, + java.util.Map userNamespaceRoles, + Set platformRoles) { Skill sourceSkill = skillRepository.findById(sourceSkillId) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId)); @@ -66,6 +69,10 @@ public class PromotionService { throw new DomainBadRequestException("promotion.version_not_published", sourceVersionId); } + if (!permissionChecker.canSubmitPromotion(sourceSkill, userId, userNamespaceRoles, platformRoles)) { + throw new DomainForbiddenException("promotion.submit.no_permission"); + } + Namespace targetNamespace = namespaceRepository.findById(targetNamespaceId) .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", targetNamespaceId)); @@ -187,4 +194,8 @@ public class PromotionService { request.setReviewedAt(Instant.now()); return request; } + + public boolean canViewPromotion(PromotionRequest request, String userId, Set platformRoles) { + return permissionChecker.canViewPromotion(request, userId, platformRoles); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index 259372c9..e2f09e8b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.review; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.skill.Skill; import org.springframework.stereotype.Component; import java.util.Map; @@ -30,21 +31,52 @@ public class ReviewPermissionChecker { return false; } + return canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles); + } + + public boolean canSubmitForReview(Skill skill, + String userId, + Map userNamespaceRoles, + Set platformRoles) { + if (skill.getOwnerId().equals(userId)) { + return true; + } + + if (platformRoles.contains("SKILL_ADMIN") + || platformRoles.contains("SUPER_ADMIN")) { + return true; + } + + NamespaceRole role = userNamespaceRoles.get(skill.getNamespaceId()); + return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; + } + + public boolean canViewReview(ReviewTask task, + String userId, + NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + if (task.getSubmittedBy().equals(userId)) { + return true; + } + return canReview(task, userId, namespaceType, userNamespaceRoles, platformRoles); + } + + public boolean canReviewNamespace(Long namespaceId, + NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { if (platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN")) { return true; } - // Global namespace: only SKILL_ADMIN or SUPER_ADMIN if (namespaceType == NamespaceType.GLOBAL) { return false; } - // Team namespace: namespace ADMIN or OWNER - NamespaceRole role = userNamespaceRoles.get( - task.getNamespaceId()); - return role == NamespaceRole.ADMIN - || role == NamespaceRole.OWNER; + NamespaceRole role = userNamespaceRoles.get(namespaceId); + return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; } /** @@ -61,4 +93,20 @@ public class ReviewPermissionChecker { return platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); } + + public boolean canSubmitPromotion(Skill skill, + String userId, + Map userNamespaceRoles, + Set platformRoles) { + return canSubmitForReview(skill, userId, userNamespaceRoles, platformRoles); + } + + public boolean canViewPromotion(PromotionRequest request, + String userId, + Set platformRoles) { + if (request.getSubmittedBy().equals(userId)) { + return true; + } + return canReviewPromotion(request, userId, platformRoles); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index a9392024..9207b8cb 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -52,9 +52,18 @@ public class ReviewService { } @Transactional - public ReviewTask submitReview(Long skillVersionId, Long namespaceId, String userId) { + public ReviewTask submitReview(Long skillVersionId, + String userId, + Map userNamespaceRoles, + Set platformRoles) { SkillVersion skillVersion = skillVersionRepository.findById(skillVersionId) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", skillVersionId)); + Skill skill = skillRepository.findById(skillVersion.getSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + + if (!permissionChecker.canSubmitForReview(skill, userId, userNamespaceRoles, platformRoles)) { + throw new DomainForbiddenException("review.submit.no_permission"); + } if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) { throw new DomainBadRequestException("review.submit.not_draft", skillVersionId); @@ -63,7 +72,7 @@ public class ReviewService { skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW); skillVersionRepository.save(skillVersion); - ReviewTask task = new ReviewTask(skillVersionId, namespaceId, userId); + ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId); try { return reviewTaskRepository.save(task); } catch (DataIntegrityViolationException e) { @@ -173,4 +182,20 @@ public class ReviewService { skillVersion.setStatus(SkillVersionStatus.DRAFT); skillVersionRepository.save(skillVersion); } + + public boolean canReviewNamespace(ReviewTask task, + String userId, + com.iflytek.skillhub.domain.namespace.NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + return permissionChecker.canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles); + } + + public boolean canViewReview(ReviewTask task, + String userId, + com.iflytek.skillhub.domain.namespace.NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + return permissionChecker.canViewReview(task, userId, namespaceType, userNamespaceRoles, platformRoles); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 009e8103..580f9c22 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -1,7 +1,6 @@ package com.iflytek.skillhub.domain.skill.service; import com.fasterxml.jackson.databind.ObjectMapper; -import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; @@ -17,7 +16,6 @@ import com.iflytek.skillhub.domain.skill.validation.PrePublishValidator; import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; import com.iflytek.skillhub.domain.skill.validation.ValidationResult; import com.iflytek.skillhub.storage.ObjectStorageService; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -50,7 +48,6 @@ public class SkillPublishService { private final SkillPackageValidator skillPackageValidator; private final SkillMetadataParser skillMetadataParser; private final PrePublishValidator prePublishValidator; - private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; private final ReviewTaskRepository reviewTaskRepository; @@ -64,7 +61,6 @@ public class SkillPublishService { SkillPackageValidator skillPackageValidator, SkillMetadataParser skillMetadataParser, PrePublishValidator prePublishValidator, - ApplicationEventPublisher eventPublisher, ObjectMapper objectMapper, ReviewTaskRepository reviewTaskRepository) { this.namespaceRepository = namespaceRepository; @@ -76,7 +72,6 @@ public class SkillPublishService { this.skillPackageValidator = skillPackageValidator; this.skillMetadataParser = skillMetadataParser; this.prePublishValidator = prePublishValidator; - this.eventPublisher = eventPublisher; this.objectMapper = objectMapper; this.reviewTaskRepository = reviewTaskRepository; } @@ -217,17 +212,13 @@ public class SkillPublishService { ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId); reviewTaskRepository.save(reviewTask); - // 12. Update skill - skill.setLatestVersionId(version.getId()); + // 12. Update skill metadata without moving the published pointer skill.setDisplayName(metadata.name()); skill.setSummary(metadata.description()); skill.setUpdatedBy(publisherId); skillRepository.save(skill); - // 13. Publish SkillPublishedEvent - eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId)); - - // 14. Return published identifiers + // 13. Return identifiers for the pending review version return new PublishResult(skill.getId(), skill.getSlug(), version); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 997b2982..0b127f28 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -230,8 +230,13 @@ public class SkillQueryService { return objectStorageService.getObject(file.getStorageKey()); } - public Page listVersions(String namespaceSlug, String skillSlug, Pageable pageable) { + public Page listVersions(String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Pageable pageable) { Skill skill = findSkill(namespaceSlug, skillSlug); + assertPublishedAccessible(skill, currentUserId, userNsRoles); List publishedVersions = skillVersionRepository.findBySkillIdAndStatus( skill.getId(), SkillVersionStatus.PUBLISHED); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java index 39ca41f9..a65ad7d0 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java @@ -22,24 +22,33 @@ public class SkillTagService { private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; private final SkillTagRepository skillTagRepository; + private final VisibilityChecker visibilityChecker; public SkillTagService( NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, - SkillTagRepository skillTagRepository) { + SkillTagRepository skillTagRepository, + VisibilityChecker visibilityChecker) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillTagRepository = skillTagRepository; + this.visibilityChecker = visibilityChecker; } - public List listTags(String namespaceSlug, String skillSlug) { + public List listTags(String namespaceSlug, + String skillSlug, + String currentUserId, + java.util.Map userNamespaceRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + if (!visibilityChecker.canAccess(skill, currentUserId, userNamespaceRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skillSlug); + } List tags = new java.util.ArrayList<>(skillTagRepository.findBySkillId(skill.getId())); if (skill.getLatestVersionId() != null) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java index 57977dc6..4f11b546 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java @@ -3,32 +3,64 @@ package com.iflytek.skillhub.domain.skill.validation; import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Set; public class SkillPackageValidator { - private static final int MAX_FILE_COUNT = 100; - private static final long MAX_SINGLE_FILE_SIZE = 1024 * 1024; // 1MB - private static final long MAX_TOTAL_PACKAGE_SIZE = 10 * 1024 * 1024; // 10MB private static final String SKILL_MD_PATH = "SKILL.md"; - private static final Set ALLOWED_EXTENSIONS = Set.of( + private static final Set DEFAULT_ALLOWED_EXTENSIONS = Set.of( ".md", ".txt", ".json", ".yaml", ".yml", ".js", ".ts", ".py", ".sh", ".png", ".jpg", ".svg" ); private final SkillMetadataParser metadataParser; + private final int maxFileCount; + private final long maxSingleFileSize; + private final long maxTotalPackageSize; + private final Set allowedExtensions; public SkillPackageValidator(SkillMetadataParser metadataParser) { + this(metadataParser, 100, 1024 * 1024, 10 * 1024 * 1024, DEFAULT_ALLOWED_EXTENSIONS); + } + + public SkillPackageValidator(SkillMetadataParser metadataParser, + int maxFileCount, + long maxSingleFileSize, + long maxTotalPackageSize, + Set allowedExtensions) { this.metadataParser = metadataParser; + this.maxFileCount = maxFileCount; + this.maxSingleFileSize = maxSingleFileSize; + this.maxTotalPackageSize = maxTotalPackageSize; + this.allowedExtensions = allowedExtensions.stream() + .map(String::toLowerCase) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); } public ValidationResult validate(List entries) { List errors = new ArrayList<>(); + Set seenPaths = new HashSet<>(); - // 1. Check SKILL.md exists at root + // 1. Check file count + if (entries.size() > maxFileCount) { + errors.add("Too many files: " + entries.size() + " (max: " + maxFileCount + ")"); + } + + // 2. Validate paths and duplicates + for (PackageEntry entry : entries) { + String normalizedPath = validateAndNormalizePath(entry.path(), errors); + if (normalizedPath != null && !seenPaths.add(normalizedPath)) { + errors.add("Duplicate file path: " + normalizedPath); + } + } + + // 3. Check SKILL.md exists at root PackageEntry skillMd = entries.stream() .filter(e -> e.path().equals(SKILL_MD_PATH)) .findFirst() @@ -39,7 +71,7 @@ public class SkillPackageValidator { return ValidationResult.fail(errors); } - // 2. Validate frontmatter + // 4. Validate frontmatter try { String content = new String(skillMd.content()); metadataParser.parse(content); @@ -50,34 +82,60 @@ public class SkillPackageValidator { errors.add("Invalid SKILL.md frontmatter: " + detail); } - // 3. Check file count - if (entries.size() > MAX_FILE_COUNT) { - errors.add("Too many files: " + entries.size() + " (max: " + MAX_FILE_COUNT + ")"); - } - - // 4. Check file extensions + // 5. Check file extensions for (PackageEntry entry : entries) { - String path = entry.path(); - boolean hasAllowedExtension = ALLOWED_EXTENSIONS.stream() - .anyMatch(path::endsWith); + String path = entry.path().toLowerCase(); + boolean hasAllowedExtension = allowedExtensions.stream().anyMatch(path::endsWith); if (!hasAllowedExtension) { errors.add("Disallowed file extension: " + path); } } - // 5. Check single file size + // 6. Check single file size for (PackageEntry entry : entries) { - if (entry.size() > MAX_SINGLE_FILE_SIZE) { - errors.add("File too large: " + entry.path() + " (" + entry.size() + " bytes, max: " + MAX_SINGLE_FILE_SIZE + ")"); + if (entry.size() > maxSingleFileSize) { + errors.add("File too large: " + entry.path() + " (" + entry.size() + " bytes, max: " + maxSingleFileSize + ")"); } } - // 6. Check total package size + // 7. Check total package size long totalSize = entries.stream().mapToLong(PackageEntry::size).sum(); - if (totalSize > MAX_TOTAL_PACKAGE_SIZE) { - errors.add("Package too large: " + totalSize + " bytes (max: " + MAX_TOTAL_PACKAGE_SIZE + ")"); + if (totalSize > maxTotalPackageSize) { + errors.add("Package too large: " + totalSize + " bytes (max: " + maxTotalPackageSize + ")"); } return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors); } + + private String validateAndNormalizePath(String path, List errors) { + if (path == null || path.isBlank()) { + errors.add("Package entry path must not be blank"); + return null; + } + if (path.contains("\\")) { + errors.add("Package entry must use '/' separators: " + path); + return null; + } + if (path.startsWith("/") || path.contains("//")) { + errors.add("Unsafe file path: " + path); + return null; + } + + try { + Path normalized = Path.of(path).normalize(); + String normalizedPath = normalized.toString().replace('\\', '/'); + if (normalized.isAbsolute() + || normalizedPath.isBlank() + || normalizedPath.equals(".") + || normalizedPath.equals("..") + || normalizedPath.startsWith("../")) { + errors.add("Unsafe file path: " + path); + return null; + } + return normalizedPath; + } catch (InvalidPathException ex) { + errors.add("Invalid file path: " + path); + return null; + } + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index b73e9e52..71677aa3 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.review; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; 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.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; @@ -20,7 +21,6 @@ import jakarta.persistence.EntityManager; import java.util.*; -import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -123,6 +123,7 @@ class PromotionServiceTest { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(permissionChecker.canSubmitPromotion(eq(sourceSkill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(globalNs)); when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.empty()); @@ -134,7 +135,8 @@ class PromotionServiceTest { }); PromotionRequest result = promotionService.submitPromotion( - SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID); + SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, + Map.of(5L, NamespaceRole.OWNER), Set.of()); assertNotNull(result); assertEquals(SOURCE_SKILL_ID, result.getSourceSkillId()); @@ -149,7 +151,7 @@ class PromotionServiceTest { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.empty()); assertThrows(DomainNotFoundException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); } @Test @@ -158,7 +160,7 @@ class PromotionServiceTest { when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.empty()); assertThrows(DomainNotFoundException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); } @Test @@ -171,7 +173,7 @@ class PromotionServiceTest { when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sv)); assertThrows(DomainBadRequestException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); } @Test @@ -184,39 +186,42 @@ class PromotionServiceTest { when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sv)); assertThrows(DomainBadRequestException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); } @Test void shouldThrowWhenTargetNamespaceNotFound() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(permissionChecker.canSubmitPromotion(any(), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.empty()); assertThrows(DomainNotFoundException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); } @Test void shouldThrowWhenTargetNamespaceNotGlobal() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(permissionChecker.canSubmitPromotion(any(), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createTeamNamespace())); assertThrows(DomainBadRequestException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); } @Test void shouldThrowWhenDuplicatePendingExists() { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(permissionChecker.canSubmitPromotion(any(), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createGlobalNamespace())); when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.of(createPendingPromotion())); assertThrows(DomainBadRequestException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID)); + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index 42bbad07..786f6af2 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -106,11 +106,14 @@ class ReviewServiceTest { @Test void shouldSubmitReviewSuccessfully() { SkillVersion sv = createDraftSkillVersion(); + Skill skill = createSkill(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(permissionChecker.canSubmitForReview(eq(skill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); ReviewTask savedTask = createPendingReviewTask(); when(reviewTaskRepository.save(any(ReviewTask.class))).thenReturn(savedTask); - ReviewTask result = reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID); + ReviewTask result = reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of()); assertNotNull(result); assertEquals(SkillVersionStatus.PENDING_REVIEW, sv.getStatus()); @@ -123,27 +126,33 @@ class ReviewServiceTest { when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.empty()); assertThrows(DomainNotFoundException.class, - () -> reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID)); + () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of())); } @Test void shouldThrowWhenStatusNotDraft() { SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(permissionChecker.canSubmitForReview(eq(skill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); assertThrows(DomainBadRequestException.class, - () -> reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID)); + () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of())); } @Test void shouldThrowOnDuplicateSubmission() { SkillVersion sv = createDraftSkillVersion(); + Skill skill = createSkill(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(permissionChecker.canSubmitForReview(eq(skill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(reviewTaskRepository.save(any(ReviewTask.class))) .thenThrow(new DataIntegrityViolationException("duplicate")); assertThrows(DomainBadRequestException.class, - () -> reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID)); + () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of())); } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 7f87af7b..9d6a8781 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -1,7 +1,6 @@ package com.iflytek.skillhub.domain.skill.service; import com.fasterxml.jackson.databind.ObjectMapper; -import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -23,7 +22,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; import java.lang.reflect.Field; import java.util.List; @@ -56,8 +54,6 @@ class SkillPublishServiceTest { @Mock private PrePublishValidator prePublishValidator; @Mock - private ApplicationEventPublisher eventPublisher; - @Mock private ReviewTaskRepository reviewTaskRepository; private SkillPublishService service; @@ -76,7 +72,6 @@ class SkillPublishServiceTest { skillPackageValidator, skillMetadataParser, prePublishValidator, - eventPublisher, objectMapper, reviewTaskRepository ); @@ -126,7 +121,6 @@ class SkillPublishServiceTest { assertEquals(1L, result.skillId()); assertEquals("test-skill", result.slug()); assertEquals("1.0.0", result.version().getVersion()); - verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); verify(skillFileRepository).saveAll(anyList()); verify(objectStorageService, atLeastOnce()).putObject(anyString(), any(), anyLong(), anyString()); verify(reviewTaskRepository).save(any(ReviewTask.class)); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java index 85b5f317..223b24dc 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java @@ -35,6 +35,8 @@ class SkillTagServiceTest { private SkillVersionRepository skillVersionRepository; @Mock private SkillTagRepository skillTagRepository; + @Mock + private VisibilityChecker visibilityChecker; private SkillTagService service; @@ -45,7 +47,8 @@ class SkillTagServiceTest { namespaceMemberRepository, skillRepository, skillVersionRepository, - skillTagRepository + skillTagRepository, + visibilityChecker ); } @@ -174,9 +177,10 @@ class SkillTagServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); when(skillTagRepository.findBySkillId(1L)).thenReturn(List.of(tag1, tag2)); + when(visibilityChecker.canAccess(eq(skill), isNull(), eq(java.util.Map.of()))).thenReturn(true); // Act - List result = service.listTags(namespaceSlug, skillSlug); + List result = service.listTags(namespaceSlug, skillSlug, null, java.util.Map.of()); // Assert assertEquals(2, result.size()); diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java index 5fd9ee8d..f322cc10 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java @@ -15,7 +15,7 @@ public class LocalFileStorageService implements ObjectStorageService { private final Path basePath; public LocalFileStorageService(StorageProperties properties) { - this.basePath = Paths.get(properties.getLocal().getBasePath()); + this.basePath = Paths.get(properties.getLocal().getBasePath()).toAbsolutePath().normalize(); } @Override @@ -58,5 +58,11 @@ public class LocalFileStorageService implements ObjectStorageService { } catch (IOException e) { throw new UncheckedIOException("Failed to get metadata: " + key, e); } } - private Path resolve(String key) { return basePath.resolve(key); } + private Path resolve(String key) { + Path resolved = basePath.resolve(key).normalize(); + if (!resolved.startsWith(basePath)) { + throw new IllegalArgumentException("Resolved path escapes storage base path: " + key); + } + return resolved; + } }