mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Revert "merge: bring review fixes into feature/project-init"
This reverts commit92f63f8b89, reversing changes made to78e16f0fe7.
This commit is contained in:
parent
1a5bc12fb7
commit
f7798dddc5
28 changed files with 235 additions and 827 deletions
3
.github/workflows/publish-images.yml
vendored
3
.github/workflows/publish-images.yml
vendored
|
|
@ -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
111
README.md
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue