mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
fix: address review-reported auth and publish issues
This commit is contained in:
parent
f7798dddc5
commit
d4deb2fb74
25 changed files with 592 additions and 182 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String> 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<String> getAllowedFileExtensions() {
|
||||
return allowedFileExtensions;
|
||||
}
|
||||
|
||||
public void setAllowedFileExtensions(Set<String> allowedFileExtensions) {
|
||||
this.allowedFileExtensions = new LinkedHashSet<>(allowedFileExtensions);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PackageEntry> entries = extractZipEntries(file);
|
||||
List<PackageEntry> 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<PackageEntry> extractZipEntries(MultipartFile file) throws IOException {
|
||||
List<PackageEntry> 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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PromotionResponseDto> submitPromotion(
|
||||
@RequestBody PromotionRequestDto request,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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<String> 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<PromotionRequest> requests = promotionRequestRepository.findByStatus(
|
||||
ReviewTaskStatus.PENDING, PageRequest.of(page, size));
|
||||
|
|
@ -99,16 +106,25 @@ public class PromotionController extends BaseApiController {
|
|||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<PromotionResponseDto> getPromotionDetail(@PathVariable Long id) {
|
||||
PromotionRequest promotion = promotionRequestRepository.findById(id).orElseThrow();
|
||||
public ApiResponse<PromotionResponseDto> 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);
|
||||
|
|
|
|||
|
|
@ -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<ReviewTaskResponse> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<ReviewTask> 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<ReviewTaskResponse> getReviewDetail(@PathVariable Long id) {
|
||||
ReviewTask task = reviewTaskRepository.findById(id).orElseThrow();
|
||||
public ApiResponse<ReviewTaskResponse> getReviewDetail(@PathVariable Long id,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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);
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> userNsRoles) {
|
||||
|
||||
Page<SkillVersion> versions = skillQueryService.listVersions(
|
||||
namespace, slug, PageRequest.of(page, size));
|
||||
namespace,
|
||||
slug,
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
PageRequest.of(page, size));
|
||||
|
||||
PageResponse<SkillVersionResponse> response = PageResponse.from(versions.map(v -> new SkillVersionResponse(
|
||||
v.getId(),
|
||||
|
|
|
|||
|
|
@ -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<PackageEntry> entries = extractZipEntries(file);
|
||||
List<PackageEntry> 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<PackageEntry> extractZipEntries(MultipartFile file) throws IOException {
|
||||
List<PackageEntry> 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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<List<TagResponse>> listTags(
|
||||
@PathVariable String namespace,
|
||||
@PathVariable String slug) {
|
||||
@PathVariable String slug,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
||||
List<SkillTag> tags = skillTagService.listTags(namespace, slug);
|
||||
List<SkillTag> tags = skillTagService.listTags(
|
||||
namespace,
|
||||
slug,
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of()
|
||||
);
|
||||
|
||||
List<TagResponse> response = tags.stream()
|
||||
.map(TagResponse::from)
|
||||
|
|
|
|||
|
|
@ -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<PackageEntry> extract(MultipartFile file) throws IOException {
|
||||
List<PackageEntry> entries = new ArrayList<>();
|
||||
Set<String> 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<String, Object> valueOperations, String deviceCode) {
|
||||
Object raw = valueOperations.get(DEVICE_CODE_PREFIX + deviceCode);
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object> valueOperations;
|
||||
@Mock
|
||||
private RedisOperations<String, Object> 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.<SessionCallback<?>>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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> 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<String> platformRoles) {
|
||||
return permissionChecker.canViewPromotion(request, userId, platformRoles);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> 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<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
if (task.getSubmittedBy().equals(userId)) {
|
||||
return true;
|
||||
}
|
||||
return canReview(task, userId, namespaceType, userNamespaceRoles, platformRoles);
|
||||
}
|
||||
|
||||
public boolean canReviewNamespace(Long namespaceId,
|
||||
NamespaceType namespaceType,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> 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<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
return canSubmitForReview(skill, userId, userNamespaceRoles, platformRoles);
|
||||
}
|
||||
|
||||
public boolean canViewPromotion(PromotionRequest request,
|
||||
String userId,
|
||||
Set<String> platformRoles) {
|
||||
if (request.getSubmittedBy().equals(userId)) {
|
||||
return true;
|
||||
}
|
||||
return canReviewPromotion(request, userId, platformRoles);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> 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<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
return permissionChecker.canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles);
|
||||
}
|
||||
|
||||
public boolean canViewReview(ReviewTask task,
|
||||
String userId,
|
||||
com.iflytek.skillhub.domain.namespace.NamespaceType namespaceType,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
return permissionChecker.canViewReview(task, userId, namespaceType, userNamespaceRoles, platformRoles);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -230,8 +230,13 @@ public class SkillQueryService {
|
|||
return objectStorageService.getObject(file.getStorageKey());
|
||||
}
|
||||
|
||||
public Page<SkillVersion> listVersions(String namespaceSlug, String skillSlug, Pageable pageable) {
|
||||
public Page<SkillVersion> listVersions(String namespaceSlug,
|
||||
String skillSlug,
|
||||
String currentUserId,
|
||||
Map<Long, NamespaceRole> userNsRoles,
|
||||
Pageable pageable) {
|
||||
Skill skill = findSkill(namespaceSlug, skillSlug);
|
||||
assertPublishedAccessible(skill, currentUserId, userNsRoles);
|
||||
|
||||
List<SkillVersion> publishedVersions = skillVersionRepository.findBySkillIdAndStatus(
|
||||
skill.getId(), SkillVersionStatus.PUBLISHED);
|
||||
|
|
|
|||
|
|
@ -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<SkillTag> listTags(String namespaceSlug, String skillSlug) {
|
||||
public List<SkillTag> listTags(String namespaceSlug,
|
||||
String skillSlug,
|
||||
String currentUserId,
|
||||
java.util.Map<Long, NamespaceRole> 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<SkillTag> tags = new java.util.ArrayList<>(skillTagRepository.findBySkillId(skill.getId()));
|
||||
if (skill.getLatestVersionId() != null) {
|
||||
|
|
|
|||
|
|
@ -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<String> ALLOWED_EXTENSIONS = Set.of(
|
||||
private static final Set<String> 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<String> 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<String> 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<PackageEntry> entries) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
Set<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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<SkillTag> result = service.listTags(namespaceSlug, skillSlug);
|
||||
List<SkillTag> result = service.listTags(namespaceSlug, skillSlug, null, java.util.Map.of());
|
||||
|
||||
// Assert
|
||||
assertEquals(2, result.size());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue