Revert "merge: bring review fixes into feature/project-init"

This reverts commit 92f63f8b89, reversing
changes made to 78e16f0fe7.
This commit is contained in:
vsxd 2026-03-13 10:06:14 +08:00
parent 1a5bc12fb7
commit f7798dddc5
28 changed files with 235 additions and 827 deletions

View file

@ -4,6 +4,7 @@ on:
push:
branches:
- main
- feature/project-init
tags:
- "v*.*.*"
workflow_dispatch:
@ -59,7 +60,7 @@ jobs:
images: ${{ matrix.image }}
tags: |
type=raw,value=edge,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=ref,event=tag
type=sha,format=short,prefix=sha-
type=semver,pattern={{version}}

111
README.md
View file

@ -37,66 +37,6 @@ firewall, with the same polish you'd expect from a public registry.
- Docker & Docker Compose
### One-Command Runtime
Published runtime images are built by GitHub Actions and pushed to GHCR.
This is the recommended path for anyone who wants a ready-to-use local
environment without building the backend or frontend on their machine.
Published images target both `linux/amd64` and `linux/arm64`.
Start the latest runtime from `main`:
```bash
curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- up
```
Start the first beta release explicitly:
```bash
curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | \
sh -s -- up --version v0.1.0-beta.1
```
Then open:
- Web UI: `http://localhost`
- Backend API: `http://localhost:8080`
The script downloads the runtime files into `${TMPDIR:-/tmp}/skillhub-runtime`
by default and starts Docker Compose from there, so it does not pollute
your current working directory.
If you want a persistent location instead of the temp directory:
```bash
curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | \
SKILLHUB_HOME=$HOME/.skillhub-runtime sh -s -- up --version v0.1.0-beta.1
```
Other useful commands:
- `curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- down`
- `curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- clean`
- `curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- ps`
- `curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- logs`
The runtime stack uses its own Compose project name, so it does not
collide with containers from `make dev-all`.
Use `clean` if you also want to remove the downloaded runtime files from
`${TMPDIR:-/tmp}/skillhub-runtime`.
The runtime uses the existing `local,docker` profile combination so it
is immediately usable with the same mock-auth flow as local development.
Available seeded users:
- `local-user`
- `local-admin`
Pass `X-Mock-User-Id` to the backend when you need an authenticated
session without configuring GitHub OAuth. If the GHCR package remains
private, run `docker login ghcr.io` before `docker compose up -d`.
### Local Development
```bash
@ -129,6 +69,57 @@ make dev-all-reset
Run `make help` to see all available commands.
### Container Runtime
Published runtime images are built by GitHub Actions and pushed to GHCR.
This is the supported path for anyone who wants a ready-to-use local
environment without building the backend or frontend on their machine.
Published images target both `linux/amd64` and `linux/arm64`.
1. Copy the runtime environment template.
2. Pick an image tag.
3. Start the stack with Docker Compose.
```bash
cp .env.release.example .env.release
```
Recommended image tags:
- `SKILLHUB_VERSION=edge` for the latest `main` build
- `SKILLHUB_VERSION=vX.Y.Z` for a fixed release
Start the runtime:
```bash
docker compose --env-file .env.release -f compose.release.yml up -d
```
Then open:
- Web UI: `http://localhost`
- Backend API: `http://localhost:8080`
Stop it with:
```bash
docker compose --env-file .env.release -f compose.release.yml down
```
The runtime stack uses its own Compose project name, so it does not
collide with containers from `make dev-all`.
The runtime uses the existing `local,docker` profile combination so it
is immediately usable with the same mock-auth flow as local development.
Available seeded users:
- `local-user`
- `local-admin`
Pass `X-Mock-User-Id` to the backend when you need an authenticated
session without configuring GitHub OAuth. If the GHCR package remains
private, run `docker login ghcr.io` before `docker compose up -d`.
## Architecture
```

View file

@ -1,174 +0,0 @@
#!/bin/sh
set -eu
COMMAND="up"
if [ "$#" -gt 0 ] && [ "${1#-}" = "$1" ]; then
COMMAND="$1"
shift
fi
SKILLHUB_REF="${SKILLHUB_REF:-main}"
SKILLHUB_HOME_DEFAULT="${TMPDIR:-/tmp}/skillhub-runtime"
SKILLHUB_HOME="${SKILLHUB_HOME:-$SKILLHUB_HOME_DEFAULT}"
SKILLHUB_VERSION_VALUE="${SKILLHUB_VERSION:-}"
SKILLHUB_SERVER_IMAGE_VALUE="${SKILLHUB_SERVER_IMAGE:-}"
SKILLHUB_WEB_IMAGE_VALUE="${SKILLHUB_WEB_IMAGE:-}"
while [ "$#" -gt 0 ]; do
case "$1" in
--version)
[ "$#" -ge 2 ] || { echo "Missing value for --version" >&2; exit 1; }
SKILLHUB_VERSION_VALUE="$2"
shift 2
;;
--home)
[ "$#" -ge 2 ] || { echo "Missing value for --home" >&2; exit 1; }
SKILLHUB_HOME="$2"
shift 2
;;
--ref)
[ "$#" -ge 2 ] || { echo "Missing value for --ref" >&2; exit 1; }
SKILLHUB_REF="$2"
shift 2
;;
--server-image)
[ "$#" -ge 2 ] || { echo "Missing value for --server-image" >&2; exit 1; }
SKILLHUB_SERVER_IMAGE_VALUE="$2"
shift 2
;;
--web-image)
[ "$#" -ge 2 ] || { echo "Missing value for --web-image" >&2; exit 1; }
SKILLHUB_WEB_IMAGE_VALUE="$2"
shift 2
;;
--help|-h)
cat <<EOF
Usage: sh runtime.sh [up|down|clean|ps|logs|pull] [options]
Options:
--version <tag> Use a specific image tag, for example v0.1.0
--home <dir> Store runtime files in a specific directory
--ref <git-ref> Download runtime files from a specific Git ref
--server-image <img> Override backend image repository
--web-image <img> Override frontend image repository
EOF
exit 0
;;
*)
echo "Unsupported argument: $1" >&2
exit 1
;;
esac
done
SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}"
COMPOSE_FILE="$SKILLHUB_HOME/compose.release.yml"
ENV_EXAMPLE_FILE="$SKILLHUB_HOME/.env.release.example"
ENV_FILE="$SKILLHUB_HOME/.env.release"
find_compose() {
if docker compose version >/dev/null 2>&1; then
echo "docker compose"
return 0
fi
if command -v docker-compose >/dev/null 2>&1; then
echo "docker-compose"
return 0
fi
echo "Docker Compose is required." >&2
exit 1
}
download_file() {
src="$1"
dest="$2"
tmp="$dest.tmp"
curl -fsSL "$src" -o "$tmp"
mv "$tmp" "$dest"
}
set_env_value() {
key="$1"
value="$2"
if [ ! -f "$ENV_FILE" ]; then
return 0
fi
tmp="$ENV_FILE.tmp"
if grep -q "^$key=" "$ENV_FILE"; then
sed "s|^$key=.*|$key=$value|" "$ENV_FILE" >"$tmp"
else
cat "$ENV_FILE" >"$tmp"
printf '%s=%s\n' "$key" "$value" >>"$tmp"
fi
mv "$tmp" "$ENV_FILE"
}
prepare_runtime_files() {
mkdir -p "$SKILLHUB_HOME"
download_file "$SKILLHUB_RAW_BASE/compose.release.yml" "$COMPOSE_FILE"
download_file "$SKILLHUB_RAW_BASE/.env.release.example" "$ENV_EXAMPLE_FILE"
if [ ! -f "$ENV_FILE" ]; then
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
fi
if [ -n "$SKILLHUB_VERSION_VALUE" ]; then
set_env_value "SKILLHUB_VERSION" "$SKILLHUB_VERSION_VALUE"
fi
if [ -n "$SKILLHUB_SERVER_IMAGE_VALUE" ]; then
set_env_value "SKILLHUB_SERVER_IMAGE" "$SKILLHUB_SERVER_IMAGE_VALUE"
fi
if [ -n "$SKILLHUB_WEB_IMAGE_VALUE" ]; then
set_env_value "SKILLHUB_WEB_IMAGE" "$SKILLHUB_WEB_IMAGE_VALUE"
fi
}
run_compose() {
compose_cmd="$(find_compose)"
# shellcheck disable=SC2086
$compose_cmd --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
}
prepare_runtime_files
case "$COMMAND" in
up)
run_compose up -d
cat <<EOF
SkillHub runtime started.
Web UI: http://localhost
Backend API: http://localhost:8080
Runtime dir: $SKILLHUB_HOME
Stop with:
curl -fsSL $SKILLHUB_RAW_BASE/scripts/runtime.sh | sh -s -- down
EOF
;;
down)
run_compose down
;;
clean)
run_compose down
rm -rf "$SKILLHUB_HOME"
;;
ps)
run_compose ps
;;
logs)
run_compose logs -f
;;
pull)
run_compose pull
;;
*)
echo "Unsupported command: $COMMAND" >&2
echo "Usage: sh runtime.sh [up|down|clean|ps|logs|pull] [options]" >&2
exit 1
;;
esac

View file

@ -15,15 +15,8 @@ public class DomainBeanConfig {
}
@Bean
public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser,
SkillPublishProperties skillPublishProperties) {
return new SkillPackageValidator(
skillMetadataParser,
skillPublishProperties.getMaxFileCount(),
skillPublishProperties.getMaxSingleFileSize(),
skillPublishProperties.getMaxPackageSize(),
skillPublishProperties.getAllowedFileExtensions()
);
public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser) {
return new SkillPackageValidator(skillMetadataParser);
}
@Bean

View file

@ -1,53 +0,0 @@
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);
}
}

View file

@ -1,7 +1,6 @@
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;
@ -13,21 +12,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")
@ -40,7 +39,7 @@ public class CliPublishController extends BaseApiController {
SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase());
List<PackageEntry> entries = zipPackageExtractor.extract(file);
List<PackageEntry> entries = extractZipEntries(file);
SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries(
namespace,
@ -61,4 +60,35 @@ 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";
}
}

View file

@ -4,13 +4,10 @@ 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;
@ -22,7 +19,6 @@ 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
@ -58,13 +54,10 @@ public class PromotionController extends BaseApiController {
@PostMapping
public ApiResponse<PromotionResponseDto> submitPromotion(
@RequestBody PromotionRequestDto request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
@RequestAttribute("userId") String userId) {
PromotionRequest promotion = promotionService.submitPromotion(
request.sourceSkillId(), request.sourceVersionId(),
request.targetNamespaceId(), userId,
userNsRoles != null ? userNsRoles : Map.of(),
rbacService.getUserRoleCodes(userId));
request.targetNamespaceId(), userId);
return ok("response.success.created", toResponse(promotion));
}
@ -98,7 +91,7 @@ public class PromotionController extends BaseApiController {
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
boolean hasAdminRole = platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN");
if (!hasAdminRole) {
throw new DomainForbiddenException("promotion.no_permission");
return ok("response.success.read", PageResponse.from(Page.empty()));
}
Page<PromotionRequest> requests = promotionRequestRepository.findByStatus(
ReviewTaskStatus.PENDING, PageRequest.of(page, size));
@ -106,25 +99,16 @@ public class PromotionController extends BaseApiController {
}
@GetMapping("/{id}")
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");
}
public ApiResponse<PromotionResponseDto> getPromotionDetail(@PathVariable Long id) {
PromotionRequest promotion = promotionRequestRepository.findById(id).orElseThrow();
return ok("response.success.read", toResponse(promotion));
}
private PromotionResponseDto toResponse(PromotionRequest req) {
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()));
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();
String submittedByName = userAccountRepository.findById(req.getSubmittedBy())
.map(UserAccount::getDisplayName).orElse(null);

View file

@ -9,8 +9,6 @@ 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;
@ -58,14 +56,11 @@ public class ReviewController extends BaseApiController {
@PostMapping
public ApiResponse<ReviewTaskResponse> submitReview(
@RequestBody ReviewTaskRequest request,
@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)
);
@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);
return ok("response.success.created", toResponse(task));
}
@ -109,15 +104,7 @@ public class ReviewController extends BaseApiController {
@RequestParam Long namespaceId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@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");
}
@RequestAttribute("userId") String userId) {
Page<ReviewTask> tasks = reviewTaskRepository.findByNamespaceIdAndStatus(
namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(page, size));
return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse)));
@ -134,27 +121,15 @@ public class ReviewController extends BaseApiController {
}
@GetMapping("/{id}")
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");
}
public ApiResponse<ReviewTaskResponse> getReviewDetail(@PathVariable Long id) {
ReviewTask task = reviewTaskRepository.findById(id).orElseThrow();
return ok("response.success.read", toResponse(task));
}
private ReviewTaskResponse toResponse(ReviewTask task) {
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()));
SkillVersion sv = skillVersionRepository.findById(task.getSkillVersionId()).orElseThrow();
Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow();
Namespace ns = namespaceRepository.findById(skill.getNamespaceId()).orElseThrow();
String submittedByName = userAccountRepository.findById(task.getSubmittedBy())
.map(UserAccount::getDisplayName).orElse(null);

View file

@ -75,16 +75,10 @@ public class SkillController extends BaseApiController {
@PathVariable String namespace,
@PathVariable String slug,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
@RequestParam(defaultValue = "20") int size) {
Page<SkillVersion> versions = skillQueryService.listVersions(
namespace,
slug,
userId,
userNsRoles != null ? userNsRoles : Map.of(),
PageRequest.of(page, size));
namespace, slug, PageRequest.of(page, size));
PageResponse<SkillVersionResponse> response = PageResponse.from(versions.map(v -> new SkillVersionResponse(
v.getId(),

View file

@ -1,7 +1,6 @@
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;
@ -13,21 +12,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")
@ -40,7 +39,7 @@ public class SkillPublishController extends BaseApiController {
SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase());
List<PackageEntry> entries = zipPackageExtractor.extract(file);
List<PackageEntry> entries = extractZipEntries(file);
SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries(
namespace,
@ -61,4 +60,35 @@ 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";
}
}

View file

@ -1,7 +1,6 @@
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;
@ -13,7 +12,6 @@ import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@ -31,16 +29,9 @@ public class SkillTagController extends BaseApiController {
@GetMapping
public ApiResponse<List<TagResponse>> listTags(
@PathVariable String namespace,
@PathVariable String slug,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
@PathVariable String slug) {
List<SkillTag> tags = skillTagService.listTags(
namespace,
slug,
userId,
userNsRoles != null ? userNsRoles : Map.of()
);
List<SkillTag> tags = skillTagService.listTags(namespace, slug);
List<TagResponse> response = tags.stream()
.map(TagResponse::from)

View file

@ -1,127 +0,0 @@
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";
}
}

View file

@ -53,15 +53,13 @@ skillhub:
access-policy:
mode: OPEN
storage:
provider: local
type: 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

View file

@ -75,21 +75,7 @@ public class SecurityConfig {
"/api/compat/v1/search",
"/api/compat/v1/resolve/**"
).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/skills", "/api/v1/skills/**").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()

View file

@ -5,10 +5,7 @@ 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;
@ -79,48 +76,24 @@ public class DeviceAuthService {
}
public DeviceTokenResponse pollToken(String 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);
DeviceCodeData data = readDeviceCodeData(deviceCode);
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();
if (data == null) {
throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid");
}
String token = apiTokenService.createToken(
consumed.getUserId(), "device-auth", "[]").rawToken();
return DeviceTokenResponse.success(token);
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");
};
}
private String generateRandomDeviceCode() {
@ -139,11 +112,7 @@ public class DeviceAuthService {
}
private DeviceCodeData readDeviceCodeData(String deviceCode) {
return readDeviceCodeData(redisTemplate.opsForValue(), deviceCode);
}
private DeviceCodeData readDeviceCodeData(ValueOperations<String, Object> valueOperations, String deviceCode) {
Object raw = valueOperations.get(DEVICE_CODE_PREFIX + deviceCode);
Object raw = redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
if (raw == null) {
return null;
}

View file

@ -9,8 +9,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.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;
@ -31,8 +29,6 @@ class DeviceAuthServiceTest {
@Mock
private ValueOperations<String, Object> valueOperations;
@Mock
private RedisOperations<String, Object> redisOperations;
@Mock
private ApiTokenService apiTokenService;
@ -41,10 +37,7 @@ class DeviceAuthServiceTest {
@BeforeEach
void setUp() {
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));
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device", new ObjectMapper());
}
@ -144,7 +137,6 @@ 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));
@ -153,17 +145,13 @@ 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));
@ -172,9 +160,6 @@ 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();
}
}

View file

@ -3,7 +3,6 @@ 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;
@ -52,9 +51,7 @@ public class PromotionService {
@Transactional
public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId,
Long targetNamespaceId, String userId,
java.util.Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
Long targetNamespaceId, String userId) {
Skill sourceSkill = skillRepository.findById(sourceSkillId)
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId));
@ -69,10 +66,6 @@ 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));
@ -194,8 +187,4 @@ 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);
}
}

View file

@ -2,7 +2,6 @@ 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;
@ -31,52 +30,21 @@ 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;
}
NamespaceRole role = userNamespaceRoles.get(namespaceId);
return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER;
// Team namespace: namespace ADMIN or OWNER
NamespaceRole role = userNamespaceRoles.get(
task.getNamespaceId());
return role == NamespaceRole.ADMIN
|| role == NamespaceRole.OWNER;
}
/**
@ -93,20 +61,4 @@ 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);
}
}

View file

@ -52,18 +52,9 @@ public class ReviewService {
}
@Transactional
public ReviewTask submitReview(Long skillVersionId,
String userId,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
public ReviewTask submitReview(Long skillVersionId, Long namespaceId, String userId) {
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);
@ -72,7 +63,7 @@ public class ReviewService {
skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW);
skillVersionRepository.save(skillVersion);
ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId);
ReviewTask task = new ReviewTask(skillVersionId, namespaceId, userId);
try {
return reviewTaskRepository.save(task);
} catch (DataIntegrityViolationException e) {
@ -182,20 +173,4 @@ 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);
}
}

View file

@ -1,6 +1,7 @@
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;
@ -16,6 +17,7 @@ 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;
@ -48,6 +50,7 @@ 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;
@ -61,6 +64,7 @@ public class SkillPublishService {
SkillPackageValidator skillPackageValidator,
SkillMetadataParser skillMetadataParser,
PrePublishValidator prePublishValidator,
ApplicationEventPublisher eventPublisher,
ObjectMapper objectMapper,
ReviewTaskRepository reviewTaskRepository) {
this.namespaceRepository = namespaceRepository;
@ -72,6 +76,7 @@ public class SkillPublishService {
this.skillPackageValidator = skillPackageValidator;
this.skillMetadataParser = skillMetadataParser;
this.prePublishValidator = prePublishValidator;
this.eventPublisher = eventPublisher;
this.objectMapper = objectMapper;
this.reviewTaskRepository = reviewTaskRepository;
}
@ -212,13 +217,17 @@ public class SkillPublishService {
ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId);
reviewTaskRepository.save(reviewTask);
// 12. Update skill metadata without moving the published pointer
// 12. Update skill
skill.setLatestVersionId(version.getId());
skill.setDisplayName(metadata.name());
skill.setSummary(metadata.description());
skill.setUpdatedBy(publisherId);
skillRepository.save(skill);
// 13. Return identifiers for the pending review version
// 13. Publish SkillPublishedEvent
eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId));
// 14. Return published identifiers
return new PublishResult(skill.getId(), skill.getSlug(), version);
}

View file

@ -230,13 +230,8 @@ public class SkillQueryService {
return objectStorageService.getObject(file.getStorageKey());
}
public Page<SkillVersion> listVersions(String namespaceSlug,
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Pageable pageable) {
public Page<SkillVersion> listVersions(String namespaceSlug, String skillSlug, Pageable pageable) {
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
List<SkillVersion> publishedVersions = skillVersionRepository.findBySkillIdAndStatus(
skill.getId(), SkillVersionStatus.PUBLISHED);

View file

@ -22,33 +22,24 @@ 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,
VisibilityChecker visibilityChecker) {
SkillTagRepository skillTagRepository) {
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,
String currentUserId,
java.util.Map<Long, NamespaceRole> userNamespaceRoles) {
public List<SkillTag> listTags(String namespaceSlug, String skillSlug) {
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) {

View file

@ -3,64 +3,32 @@ 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> DEFAULT_ALLOWED_EXTENSIONS = Set.of(
private static final Set<String> 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 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
// 1. Check SKILL.md exists at root
PackageEntry skillMd = entries.stream()
.filter(e -> e.path().equals(SKILL_MD_PATH))
.findFirst()
@ -71,7 +39,7 @@ public class SkillPackageValidator {
return ValidationResult.fail(errors);
}
// 4. Validate frontmatter
// 2. Validate frontmatter
try {
String content = new String(skillMd.content());
metadataParser.parse(content);
@ -82,60 +50,34 @@ public class SkillPackageValidator {
errors.add("Invalid SKILL.md frontmatter: " + detail);
}
// 5. Check file extensions
// 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
for (PackageEntry entry : entries) {
String path = entry.path().toLowerCase();
boolean hasAllowedExtension = allowedExtensions.stream().anyMatch(path::endsWith);
String path = entry.path();
boolean hasAllowedExtension = ALLOWED_EXTENSIONS.stream()
.anyMatch(path::endsWith);
if (!hasAllowedExtension) {
errors.add("Disallowed file extension: " + path);
}
}
// 6. Check single file size
// 5. Check single file size
for (PackageEntry entry : entries) {
if (entry.size() > maxSingleFileSize) {
errors.add("File too large: " + entry.path() + " (" + entry.size() + " bytes, max: " + maxSingleFileSize + ")");
if (entry.size() > MAX_SINGLE_FILE_SIZE) {
errors.add("File too large: " + entry.path() + " (" + entry.size() + " bytes, max: " + MAX_SINGLE_FILE_SIZE + ")");
}
}
// 7. Check total package size
// 6. Check total package size
long totalSize = entries.stream().mapToLong(PackageEntry::size).sum();
if (totalSize > maxTotalPackageSize) {
errors.add("Package too large: " + totalSize + " bytes (max: " + maxTotalPackageSize + ")");
if (totalSize > MAX_TOTAL_PACKAGE_SIZE) {
errors.add("Package too large: " + totalSize + " bytes (max: " + MAX_TOTAL_PACKAGE_SIZE + ")");
}
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;
}
}
}

View file

@ -2,7 +2,6 @@ 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;
@ -21,6 +20,7 @@ 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,7 +123,6 @@ 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());
@ -135,8 +134,7 @@ class PromotionServiceTest {
});
PromotionRequest result = promotionService.submitPromotion(
SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID,
Map.of(5L, NamespaceRole.OWNER), Set.of());
SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID);
assertNotNull(result);
assertEquals(SOURCE_SKILL_ID, result.getSourceSkillId());
@ -151,7 +149,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, Map.of(), Set.of()));
() -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID));
}
@Test
@ -160,7 +158,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, Map.of(), Set.of()));
() -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID));
}
@Test
@ -173,7 +171,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, Map.of(), Set.of()));
() -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID));
}
@Test
@ -186,42 +184,39 @@ 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, Map.of(), Set.of()));
() -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID));
}
@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, Map.of(), Set.of()));
() -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID));
}
@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, Map.of(), Set.of()));
() -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID));
}
@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, Map.of(), Set.of()));
() -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID));
}
}

View file

@ -106,14 +106,11 @@ 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, USER_ID, Map.of(), Set.of());
ReviewTask result = reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID);
assertNotNull(result);
assertEquals(SkillVersionStatus.PENDING_REVIEW, sv.getStatus());
@ -126,33 +123,27 @@ class ReviewServiceTest {
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.empty());
assertThrows(DomainNotFoundException.class,
() -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of()));
() -> reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID));
}
@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, USER_ID, Map.of(), Set.of()));
() -> reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID));
}
@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, USER_ID, Map.of(), Set.of()));
() -> reviewService.submitReview(SKILL_VERSION_ID, NAMESPACE_ID, USER_ID));
}
}

View file

@ -1,6 +1,7 @@
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;
@ -22,6 +23,7 @@ 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;
@ -54,6 +56,8 @@ class SkillPublishServiceTest {
@Mock
private PrePublishValidator prePublishValidator;
@Mock
private ApplicationEventPublisher eventPublisher;
@Mock
private ReviewTaskRepository reviewTaskRepository;
private SkillPublishService service;
@ -72,6 +76,7 @@ class SkillPublishServiceTest {
skillPackageValidator,
skillMetadataParser,
prePublishValidator,
eventPublisher,
objectMapper,
reviewTaskRepository
);
@ -121,6 +126,7 @@ 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));

View file

@ -35,8 +35,6 @@ class SkillTagServiceTest {
private SkillVersionRepository skillVersionRepository;
@Mock
private SkillTagRepository skillTagRepository;
@Mock
private VisibilityChecker visibilityChecker;
private SkillTagService service;
@ -47,8 +45,7 @@ class SkillTagServiceTest {
namespaceMemberRepository,
skillRepository,
skillVersionRepository,
skillTagRepository,
visibilityChecker
skillTagRepository
);
}
@ -177,10 +174,9 @@ 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, null, java.util.Map.of());
List<SkillTag> result = service.listTags(namespaceSlug, skillSlug);
// Assert
assertEquals(2, result.size());

View file

@ -15,7 +15,7 @@ public class LocalFileStorageService implements ObjectStorageService {
private final Path basePath;
public LocalFileStorageService(StorageProperties properties) {
this.basePath = Paths.get(properties.getLocal().getBasePath()).toAbsolutePath().normalize();
this.basePath = Paths.get(properties.getLocal().getBasePath());
}
@Override
@ -58,11 +58,5 @@ public class LocalFileStorageService implements ObjectStorageService {
} catch (IOException e) { throw new UncheckedIOException("Failed to get metadata: " + key, e); }
}
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;
}
private Path resolve(String key) { return basePath.resolve(key); }
}