diff --git a/.env.release.example b/.env.release.example new file mode 100644 index 00000000..c41ccee5 --- /dev/null +++ b/.env.release.example @@ -0,0 +1,18 @@ +# `edge` tracks the latest build from the default branch. +# For deterministic environments, pin a release tag like `v0.1.0`. +SKILLHUB_VERSION=edge +SKILLHUB_SERVER_IMAGE=ghcr.io/iflytek/skillhub-server +SKILLHUB_WEB_IMAGE=ghcr.io/iflytek/skillhub-web + +POSTGRES_PORT=5432 +POSTGRES_DB=skillhub +POSTGRES_USER=skillhub +POSTGRES_PASSWORD=skillhub_demo + +REDIS_PORT=6379 +API_PORT=8080 +WEB_PORT=80 + +# Optional: configure real GitHub OAuth before exposing the stack to other users. +OAUTH2_GITHUB_CLIENT_ID=local-placeholder +OAUTH2_GITHUB_CLIENT_SECRET=local-placeholder diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..e5ce7df0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,40 @@ +name: Bug Report +description: Report a defect in SkillHub +title: "[Bug] " +labels: + - bug +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What happened? + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps To Reproduce + description: Include commands, requests, or UI flow + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected Behavior + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Branch, commit, runtime profile, browser, OS, etc. + - type: textarea + id: api-impact + attributes: + label: API Contract Impact + description: If relevant, include the request path, response shape, and whether `web/src/api/generated/schema.d.ts` appears stale. + - type: textarea + id: logs + attributes: + label: Logs Or Screenshots diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..3ed8eaeb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security Report + url: https://github.com/iflytek/skillhub/security/advisories/new + about: Do not file public issues for suspected vulnerabilities. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..f3ae637d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,33 @@ +name: Feature Request +description: Propose a new capability or workflow improvement +title: "[Feature] " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What user or operator problem does this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed Solution + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + - type: textarea + id: impact + attributes: + label: Impact + description: Auth, API, migration, deployment, observability, or UX impact + - type: textarea + id: contract + attributes: + label: Contract Or SDK Impact + description: Note whether this proposal changes OpenAPI, generated SDKs, CLI protocol, or operator docs. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..1e9094b2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,29 @@ +## Summary + +- What changed? +- Why is this needed? + +## Validation + +- [ ] Backend tests passed +- [ ] Frontend typecheck/build passed +- [ ] OpenAPI SDK regenerated or checked when API contracts changed +- [ ] Smoke test run when relevant + +Commands run: + +```bash +# paste commands here +``` + +## Risk + +- User-facing impact: +- Deployment or migration impact: +- Rollback approach: + +## Notes + +- Related issue: +- Follow-up work: +- Docs or operator runbooks updated when behavior changed: diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml new file mode 100644 index 00000000..74655dc6 --- /dev/null +++ b/.github/workflows/publish-images.yml @@ -0,0 +1,81 @@ +name: Publish Images + +on: + push: + branches: + - main + - feature/project-init + tags: + - "v*.*.*" + workflow_dispatch: + +concurrency: + group: publish-images-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +env: + DOCKER_PLATFORMS: linux/amd64,linux/arm64 + +jobs: + publish: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: server + context: ./server + dockerfile: ./server/Dockerfile + image: ghcr.io/${{ github.repository_owner }}/skillhub-server + - name: web + context: ./web + dockerfile: ./web/Dockerfile + image: ghcr.io/${{ github.repository_owner }}/skillhub-web + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ matrix.image }} + tags: | + type=raw,value=edge,enable={{is_default_branch}} + 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}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push ${{ matrix.name }} + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + platforms: ${{ env.DOCKER_PLATFORMS }} + push: true + provenance: false + sbom: false + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.name }} + cache-to: type=gha,mode=max,scope=${{ matrix.name }} diff --git a/.github/workflows/validate-openapi.yml b/.github/workflows/validate-openapi.yml new file mode 100644 index 00000000..c912e046 --- /dev/null +++ b/.github/workflows/validate-openapi.yml @@ -0,0 +1,43 @@ +name: Validate OpenAPI SDK + +on: + pull_request: + push: + branches: + - main + - feature/project-init + +jobs: + openapi-sdk: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install frontend dependencies + working-directory: web + run: pnpm install --frozen-lockfile + + - name: Validate generated OpenAPI SDK + run: ./scripts/check-openapi-generated.sh diff --git a/.gitignore b/.gitignore index c0900a12..2129c847 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,8 @@ coverage/ # Temporary files .tmp/ tmp/ +__pycache__/ +*.py[cod] # Git worktrees .worktrees/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..f1dc7ad0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,33 @@ +# Code of Conduct + +## Our Standard + +Contributors and maintainers are expected to keep discussion technical, +respectful, and constructive. + +Examples of expected behavior: + +- Focus on the problem, tradeoffs, and evidence. +- Assume good intent, but challenge weak reasoning directly. +- Share actionable feedback. +- Respect different levels of experience and domain knowledge. + +Examples of unacceptable behavior: + +- Harassment, insults, or personal attacks +- Bad-faith argumentation or repeated hostility +- Publishing private or sensitive information without permission +- Disruptive behavior that blocks productive collaboration + +## Enforcement + +Project maintainers may remove comments, reject contributions, or restrict +participation for behavior that violates this code of conduct. + +Serious or repeated violations may result in a temporary or permanent ban from +project spaces. + +## Reporting + +Report conduct issues privately to the maintainers through a private maintainer +channel. Do not use public issues for personal or sensitive reports. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a382e5d7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,81 @@ +# Contributing to SkillHub + +## Scope + +SkillHub is a self-hosted registry for agent skills. Contributions should +preserve the existing architecture and product direction documented in +[`docs/`](./docs). + +## Before You Start + +- Read [`README.md`](./README.md) for local development commands. +- Check the relevant design docs before changing behavior. +- Open an issue for non-trivial changes before sending a large pull request. + +## Development Setup + +Prerequisites: + +- Docker and Docker Compose +- Java 21 +- Node.js and `pnpm` + +Start the local stack: + +```bash +make dev-all +``` + +Useful commands: + +```bash +make test +make typecheck-web +make build-web +make generate-api +./scripts/check-openapi-generated.sh +./scripts/smoke-test.sh +``` + +Stop the stack: + +```bash +make dev-all-down +``` + +## Change Guidelines + +- Keep changes focused. Avoid mixing refactors with behavior changes. +- Follow existing module boundaries across `server/`, `web/`, and `docs/`. +- Add or update tests when behavior changes. +- Update docs when APIs, auth flows, deployment, or operator workflows change. +- Regenerate and commit `web/src/api/generated/schema.d.ts` when backend OpenAPI + contracts change. +- Prefer backward-compatible changes unless the issue explicitly allows a break. + +## Pull Requests + +Before opening a pull request, make sure: + +- The branch is rebased or merged cleanly from the target branch. +- Relevant backend tests pass. +- Frontend typecheck/build passes when frontend files changed. +- `make generate-api` or `./scripts/check-openapi-generated.sh` has been run when + backend API contracts changed. +- Smoke coverage is updated when operator-facing workflows change. +- The pull request description explains motivation, scope, and rollout impact. + +## Commit Style + +Conventional-style subjects are preferred, for example: + +- `feat(auth): add local account login` +- `fix(ops): align smoke test with csrf flow` +- `docs(deploy): clarify runtime image usage` + +## Reporting Security Issues + +Do not open public issues for suspected security vulnerabilities. + +Use GitHub Security Advisories or your internal security process to report them +privately to the maintainers. diff --git a/LICENSE b/LICENSE index 97d30276..0c5fba18 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,201 @@ -MIT License + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Copyright (c) 2026 iFLYTEK + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + 1. Definitions. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets.) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile index 5417e36f..ed926367 100644 --- a/Makefile +++ b/Makefile @@ -14,9 +14,7 @@ help: ## 显示帮助 awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' dev: ## 启动本地开发环境(仅依赖服务) - docker compose up -d - @echo "Waiting for services to be healthy..." - @sleep 5 + docker compose up -d --wait --remove-orphans @echo "Services ready." @echo "Start backend with: make dev-server" @echo "Start frontend with: make dev-web" @@ -28,13 +26,13 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) echo "Installing frontend dependencies..."; \ $(MAKE) web-install; \ fi - @if [ -f $(DEV_SERVER_PID) ] && kill -0 "$$(cat $(DEV_SERVER_PID))" 2>/dev/null; then \ + @if $(DEV_PROCESS) status --pid-file $(DEV_SERVER_PID) >/dev/null 2>&1; then \ echo "Backend already running with PID $$(cat $(DEV_SERVER_PID))"; \ else \ echo "Starting backend..."; \ - $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local >/dev/null; \ + $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' >/dev/null; \ fi - @if [ -f $(DEV_WEB_PID) ] && kill -0 "$$(cat $(DEV_WEB_PID))" 2>/dev/null; then \ + @if $(DEV_PROCESS) status --pid-file $(DEV_WEB_PID) >/dev/null 2>&1; then \ echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \ else \ echo "Starting frontend..."; \ @@ -42,13 +40,24 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) fi @echo "Waiting for backend on $(DEV_API_URL) ..." @backend_ready=0; \ - for i in $$(seq 1 60); do \ - if curl -sf $(DEV_API_URL)/actuator/health >/dev/null; then \ - echo "Backend ready."; \ - backend_ready=1; \ - break; \ + for attempt in 1 2; do \ + for i in $$(seq 1 30); do \ + if curl -sf $(DEV_API_URL)/actuator/health >/dev/null; then \ + echo "Backend ready."; \ + backend_ready=1; \ + break 2; \ + fi; \ + if ! $(DEV_PROCESS) status --pid-file $(DEV_SERVER_PID) >/dev/null 2>&1; then \ + break; \ + fi; \ + sleep 2; \ + done; \ + if [ "$$attempt" -lt 2 ]; then \ + echo "Backend did not become ready on attempt $$attempt. Restarting..."; \ + $(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID); \ + sleep 2; \ + $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' >/dev/null; \ fi; \ - sleep 2; \ done; \ if [ "$$backend_ready" -ne 1 ]; then \ echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \ @@ -71,15 +80,18 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) @echo "Local environment is ready:" @echo " Web UI: $(DEV_WEB_URL)" @echo " Backend: $(DEV_API_URL)" + @echo "Mock auth users:" + @echo " local-user -> X-Mock-User-Id: local-user" + @echo " local-admin -> X-Mock-User-Id: local-admin" @echo "Logs:" @echo " Backend: $(DEV_SERVER_LOG)" @echo " Frontend: $(DEV_WEB_LOG)" dev-server: ## 启动后端开发服务器 - cd server && ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local + cd server && /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' dev-down: ## 停止本地开发环境 - docker compose down + docker compose down --remove-orphans dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端) @$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID) @@ -89,7 +101,7 @@ dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端) dev-all-reset: ## 重置本地开发环境(清理依赖数据卷后重新启动) @$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID) @$(DEV_PROCESS) stop --pid-file $(DEV_WEB_PID) - docker compose down -v + docker compose down -v --remove-orphans rm -rf $(DEV_DIR) @$(MAKE) dev-all @@ -127,8 +139,6 @@ lint-web: ## 前端代码检查 cd web && pnpm run lint db-reset: ## 重置数据库 - docker compose down -v - docker compose up -d postgres - @echo "Waiting for postgres..." - @sleep 3 + docker compose down -v --remove-orphans + docker compose up -d --wait --remove-orphans postgres cd server && ./mvnw flyway:migrate -pl skillhub-app diff --git a/README.md b/README.md index c0695001..aaa2c4c8 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,19 @@ firewall, with the same polish you'd expect from a public registry. Each namespace has its own members, roles (Owner / Admin / Member), and publishing policies. - **Review & Governance** — Team admins review within their namespace; - platform admins gate promotions to the global scope. Every - action is audit-logged for compliance. + platform admins gate promotions to the global scope. Governance + actions are audit-logged for compliance. - **CLI-First** — Native REST API plus a compatibility layer for - existing ClawHub CLI tools — no client changes needed. + existing ClawHub-style registry clients. Native CLI APIs are the + primary supported path while protocol compatibility continues to + expand. - **Pluggable Storage** — Local filesystem for development, S3 / MinIO for production. Swap via config. ## Quick Start +Start the full local stack with: `curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh | sh -s -- up` + ### Prerequisites - Docker & Docker Compose @@ -48,6 +52,13 @@ Then open: - Web UI: `http://localhost:3000` - Backend API: `http://localhost:8080` +Local profile seeds two mock-auth users automatically: + +- `local-user` for normal publishing and namespace operations +- `local-admin` with `SUPER_ADMIN` for review and admin flows + +Use them with the `X-Mock-User-Id` header in local development. + Stop everything with: ```bash @@ -62,6 +73,129 @@ make dev-all-reset Run `make help` to see all available commands. +### API Contract Sync + +OpenAPI types for the web client are checked into the repository. +When backend API contracts change, regenerate the SDK and commit the +updated generated file: + +```bash +make generate-api +``` + +For a stricter end-to-end drift check, run: + +```bash +./scripts/check-openapi-generated.sh +``` + +This starts local dependencies, boots the backend, regenerates the +frontend schema, and fails if the checked-in SDK is stale. + +### 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`. + +### Monitoring + +The Phase 4 monitoring stack lives under [`monitoring/`](./monitoring). +It provides a local Prometheus + Grafana pair that scrapes the backend's +Actuator Prometheus endpoint. + +Start it with: + +```bash +cd monitoring +docker compose -f docker-compose.monitoring.yml up -d +``` + +Then open: + +- Prometheus: `http://localhost:9090` +- Grafana: `http://localhost:3001` (`admin` / `admin`) + +By default Prometheus scrapes `http://host.docker.internal:8080/actuator/prometheus`, +so start the backend locally on port `8080` first. + +## Kubernetes + +Basic Kubernetes manifests are available under [`deploy/k8s/`](./deploy/k8s): + +- `configmap.yaml` +- `secret.yaml.example` +- `backend-deployment.yaml` +- `frontend-deployment.yaml` +- `services.yaml` +- `ingress.yaml` + +Apply them after creating your own secret: + +```bash +kubectl apply -f deploy/k8s/configmap.yaml +kubectl apply -f deploy/k8s/secret.yaml +kubectl apply -f deploy/k8s/backend-deployment.yaml +kubectl apply -f deploy/k8s/frontend-deployment.yaml +kubectl apply -f deploy/k8s/services.yaml +kubectl apply -f deploy/k8s/ingress.yaml +``` + +## Smoke Test + +A lightweight smoke test script is available at [`scripts/smoke-test.sh`](./scripts/smoke-test.sh). + +Run it against a local backend: + +```bash +./scripts/smoke-test.sh http://localhost:8080 +``` + ## Architecture ``` @@ -81,9 +215,9 @@ Run `make help` to see all available commands. │ ┌────────────┼────────────┐ │ │ │ - ┌──────▼───┐ ┌─────▼────┐ ┌───▼────┐ - │PostgreSQL│ │ Redis │ │ MinIO │ - └──────────┘ └──────────┘ └────────┘ + ┌──────▼───┐ ┌─────▼────┐ ┌────▼────┐ + │PostgreSQL│ │ Redis │ │ Storage │ + └──────────┘ └──────────┘ └─────────┘ ``` ## Contributing @@ -91,6 +225,12 @@ Run `make help` to see all available commands. Contributions are welcome. Please open an issue first to discuss what you'd like to change. +- Contribution guide: [`CONTRIBUTING.md`](./CONTRIBUTING.md) +- Code of conduct: [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md) + +- Contribution guide: [`CONTRIBUTING.md`](./CONTRIBUTING.md) +- Code of conduct: [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md) + ## License -MIT +Apache License 2.0 diff --git a/compose.release.yml b/compose.release.yml new file mode 100644 index 00000000..04c04395 --- /dev/null +++ b/compose.release.yml @@ -0,0 +1,76 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + ports: + - "${POSTGRES_PORT:-5432}:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB:-skillhub} + POSTGRES_USER: ${POSTGRES_USER:-skillhub} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skillhub} -d ${POSTGRES_DB:-skillhub}"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + + server: + image: ${SKILLHUB_SERVER_IMAGE:-ghcr.io/iflytek/skillhub-server}:${SKILLHUB_VERSION:-edge} + restart: unless-stopped + ports: + - "${API_PORT:-8080}:8080" + environment: + SPRING_PROFILES_ACTIVE: local,docker + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-skillhub} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-skillhub} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo} + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 + STORAGE_BASE_PATH: /var/lib/skillhub/storage + OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder} + OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder} + volumes: + - skillhub_storage:/var/lib/skillhub/storage + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + + web: + image: ${SKILLHUB_WEB_IMAGE:-ghcr.io/iflytek/skillhub-web}:${SKILLHUB_VERSION:-edge} + restart: unless-stopped + ports: + - "${WEB_PORT:-80}:80" + depends_on: + server: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost/nginx-health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s + +volumes: + postgres_data: + skillhub_storage: diff --git a/deploy/k8s/backend-deployment.yaml b/deploy/k8s/backend-deployment.yaml new file mode 100644 index 00000000..7c2af196 --- /dev/null +++ b/deploy/k8s/backend-deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: skillhub-server + labels: + app.kubernetes.io/name: skillhub-server +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: skillhub-server + template: + metadata: + labels: + app.kubernetes.io/name: skillhub-server + spec: + containers: + - name: server + image: ghcr.io/iflytek/skillhub-server:edge + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + name: http + env: + - name: SPRING_PROFILES_ACTIVE + value: docker + - name: SPRING_DATASOURCE_URL + valueFrom: + secretKeyRef: + name: skillhub-secret + key: spring-datasource-url + - name: SPRING_DATASOURCE_USERNAME + valueFrom: + secretKeyRef: + name: skillhub-secret + key: spring-datasource-username + - name: SPRING_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: skillhub-secret + key: spring-datasource-password + - name: SPRING_DATA_REDIS_HOST + valueFrom: + configMapKeyRef: + name: skillhub-config + key: redis-host + - name: SPRING_DATA_REDIS_PORT + valueFrom: + configMapKeyRef: + name: skillhub-config + key: redis-port + - name: STORAGE_BASE_PATH + valueFrom: + configMapKeyRef: + name: skillhub-config + key: storage-base-path + - name: SESSION_COOKIE_SECURE + value: "true" + - name: OAUTH2_GITHUB_CLIENT_ID + valueFrom: + secretKeyRef: + name: skillhub-secret + key: oauth2-github-client-id + optional: true + - name: OAUTH2_GITHUB_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: skillhub-secret + key: oauth2-github-client-secret + optional: true + volumeMounts: + - name: skillhub-storage + mountPath: /var/lib/skillhub/storage + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: http + initialDelaySeconds: 20 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: http + initialDelaySeconds: 30 + periodSeconds: 15 + volumes: + - name: skillhub-storage + persistentVolumeClaim: + claimName: skillhub-storage-pvc diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml new file mode 100644 index 00000000..e3988d82 --- /dev/null +++ b/deploy/k8s/configmap.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: skillhub-config +data: + redis-host: redis + redis-port: "6379" + storage-base-path: /var/lib/skillhub/storage +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: skillhub-storage-pvc +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi diff --git a/deploy/k8s/frontend-deployment.yaml b/deploy/k8s/frontend-deployment.yaml new file mode 100644 index 00000000..05538617 --- /dev/null +++ b/deploy/k8s/frontend-deployment.yaml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: skillhub-web + labels: + app.kubernetes.io/name: skillhub-web +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: skillhub-web + template: + metadata: + labels: + app.kubernetes.io/name: skillhub-web + spec: + containers: + - name: web + image: ghcr.io/iflytek/skillhub-web:edge + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + name: http + readinessProbe: + httpGet: + path: /nginx-health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /nginx-health + port: http + initialDelaySeconds: 10 + periodSeconds: 15 diff --git a/deploy/k8s/ingress.yaml b/deploy/k8s/ingress.yaml new file mode 100644 index 00000000..89ff0980 --- /dev/null +++ b/deploy/k8s/ingress.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: skillhub + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: 100m +spec: + ingressClassName: nginx + rules: + - host: skills.example.com + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: skillhub-server + port: + number: 8080 + - path: / + pathType: Prefix + backend: + service: + name: skillhub-web + port: + number: 80 diff --git a/deploy/k8s/secret.yaml.example b/deploy/k8s/secret.yaml.example new file mode 100644 index 00000000..1aac6fd9 --- /dev/null +++ b/deploy/k8s/secret.yaml.example @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: skillhub-secret +type: Opaque +stringData: + spring-datasource-url: jdbc:postgresql://postgres:5432/skillhub + spring-datasource-username: skillhub + spring-datasource-password: change-me + oauth2-github-client-id: your-client-id + oauth2-github-client-secret: your-client-secret diff --git a/deploy/k8s/services.yaml b/deploy/k8s/services.yaml new file mode 100644 index 00000000..d7a741a4 --- /dev/null +++ b/deploy/k8s/services.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + name: skillhub-server + labels: + app.kubernetes.io/name: skillhub-server +spec: + selector: + app.kubernetes.io/name: skillhub-server + ports: + - name: http + port: 8080 + targetPort: http +--- +apiVersion: v1 +kind: Service +metadata: + name: skillhub-web + labels: + app.kubernetes.io/name: skillhub-web +spec: + selector: + app.kubernetes.io/name: skillhub-web + ports: + - name: http + port: 80 + targetPort: http diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index 3d5b7374..00000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,86 +0,0 @@ -services: - postgres: - image: postgres:16-alpine - ports: - - "5432:5432" - environment: - POSTGRES_DB: skillhub - POSTGRES_USER: skillhub - POSTGRES_PASSWORD: ${DB_PASSWORD:-skillhub_prod} - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U skillhub"] - interval: 5s - timeout: 5s - retries: 5 - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 5s - retries: 5 - - minio: - image: minio/minio:latest - ports: - - "9000:9000" - - "9001:9001" - environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} - command: server /data --console-address ":9001" - volumes: - - minio_data:/data - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] - interval: 5s - timeout: 5s - retries: 5 - - server: - build: - context: ./server - dockerfile: Dockerfile - ports: - - "8080:8080" - environment: - SPRING_PROFILES_ACTIVE: prod - SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/skillhub - SPRING_DATASOURCE_USERNAME: skillhub - SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-skillhub_prod} - SPRING_DATA_REDIS_HOST: redis - SPRING_DATA_REDIS_PORT: 6379 - OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID} - OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET} - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - minio: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] - interval: 10s - timeout: 5s - retries: 10 - start_period: 30s - - web: - build: - context: ./web - dockerfile: Dockerfile - ports: - - "80:80" - depends_on: - server: - condition: service_healthy - -volumes: - postgres_data: - minio_data: diff --git a/docs/01-system-architecture.md b/docs/01-system-architecture.md index ba932eab..ec754373 100644 --- a/docs/01-system-architecture.md +++ b/docs/01-system-architecture.md @@ -114,6 +114,9 @@ skillhub/ │ ├── Dockerfile # 前端多阶段构建 │ └── nginx.conf # Nginx 配置(SPA 路由 + API 反向代理) ├── docker-compose.yml # 本地开发依赖服务(PostgreSQL/Redis/MinIO) +├── compose.release.yml # 单机运行时编排(发布镜像 + PostgreSQL + Redis) +├── .env.release.example # 单机运行时环境变量模板 +├── .github/workflows/ # GitHub Actions 镜像发布流程 ├── Makefile # 顶层开发编排(dev / dev-all / build) ├── docs/ # 设计文档 └── README.md @@ -123,10 +126,20 @@ skillhub/ ## 8. 部署架构 -同域部署,统一入口: -- `https://skills.example.com/` → 前端静态资源 -- `https://skills.example.com/api/*` → 反向代理到 Spring Boot -- 生产环境通过 Nginx 或网关统一接入 +部署模型收敛为两条路径: + +- 开发路径:`make dev-all`。前后端在宿主机运行,`docker-compose.yml` 只负责 PostgreSQL、Redis、MinIO。 +- 交付路径:GitHub Actions 构建并发布 `server` / `web` 镜像;用户通过 `compose.release.yml` 在本地一键拉起前后端容器和基础服务。 +- 发布镜像为多架构 manifest,至少覆盖 `linux/amd64` 与 `linux/arm64`。 + +单机运行时统一入口: +- `http://localhost/` → Web 容器(Nginx) +- `http://localhost/api/*` → Web 容器反向代理到 Spring Boot +- `http://localhost:8080/actuator/health` → 后端健康检查 + +单机运行时使用 `local,docker` profile 组合: +- `local` 提供 mock 登录和种子账号,保证拉起即用 +- `docker` 负责将数据库、Redis 地址切换到 Compose 网络 ## 9. 分布式环境要求 @@ -148,3 +161,5 @@ skillhub/ - 缓存/Session:Spring Session + Redis - 数据库迁移:Flyway - 认证:Spring Security OAuth2 Client(一期 GitHub) +- 镜像发布:GitHub Actions 推送至 GHCR,默认维护 `edge` 与语义化版本标签 +- 运行时兼容:发布镜像默认输出 `linux/amd64` + `linux/arm64` 多架构 manifest diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 21f010ee..9b5b13ea 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -1,350 +1,179 @@ # skillhub 部署架构与运维 -## 1 K8s 部署拓扑 +## 1 运行模型 + +当前仓库只保留两种运行方式: + +- 开发环境:`make dev-all` + - 前端和后端运行在宿主机 + - `docker-compose.yml` 只负责 PostgreSQL、Redis、MinIO +- 单机交付环境:`docker compose --env-file .env.release -f compose.release.yml up -d` + - 前端和后端都运行在容器内 +- 使用 GitHub Actions 发布到 GHCR 的镜像 +- 默认发布 `linux/amd64` 与 `linux/arm64` 多架构镜像 + - PostgreSQL、Redis 与应用容器一起通过 Compose 启动 + +不再维护本地构建整套 demo 容器的中间模式,也不再保留 `docker-compose.prod.yml`。 + +## 2 单机交付拓扑 ``` - ┌─────────────┐ - │ Ingress │ - │ (Nginx) │ - └──────┬──────┘ - │ - ┌────────────┴────────────┐ - │ /api/* │ /* - ▼ ▼ - ┌──────────────────┐ ┌──────────────────┐ - │ Spring Boot │ │ Nginx / CDN │ - │ replicas: 2+ │ │ 静态资源 │ - └────────┬─────────┘ └──────────────────┘ - │ - ┌────────┴──────────────────────┐ - │ │ │ - ▼ ▼ ▼ -┌────────┐ ┌────────┐ ┌──────────────┐ -│ PostgreSQL│ │ Redis │ │ S3 / MinIO │ -│ (主从) │ │ │ │ │ -└────────┘ └────────┘ └──────────────┘ +┌──────────────┐ +│ Browser / CLI│ +└──────┬───────┘ + │ + ▼ +┌──────────────┐ +│ Web/Nginx │ published image +└──────┬───────┘ + │ /api/* + ▼ +┌──────────────┐ +│ Spring Boot │ published image +└───┬────┬─────┘ + │ │ + ▼ ▼ + PostgreSQL Redis ``` -## 2 服务配置 +说明: +- Web 容器提供静态资源,并将 `/api/*`、`/oauth2/*`、`/.well-known/*` 反代到后端 +- 后端运行 `local,docker` profile 组合 +- 技能包文件默认落在容器卷 `skillhub_storage`,保证单机环境开箱即用 -- 无状态设计,所有状态存储在 PostgreSQL / Redis / S3 -- 健康检查:`/actuator/health`(liveness + readiness 分离) -- 优雅停机:`spring.lifecycle.timeout-per-shutdown-phase=30s` -- JVM:`-XX:MaxRAMPercentage=75.0` +## 3 Profile 约定 -## 3 环境 Profile - -| Profile | 用途 | 特点 | +| Profile | 用途 | 说明 | |---------|------|------| -| `local` | 本地开发 | Docker Compose 一键启动(PostgreSQL/Redis/MinIO),Mock OAuth(见下方说明) | -| `dev` | 开发环境 | 共享基础设施,GitHub OAuth 测试应用 | -| `staging` | 预发布 | 与生产同构 | -| `prod` | 生产 | 多 Pod,完整基础设施 | +| `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 | +| `docker` | 容器网络适配 | 将数据库和 Redis 地址切换到 Compose 内网 | -### 本地开发 Mock 登录 +单机交付环境使用 `SPRING_PROFILES_ACTIVE=local,docker`,原因很明确: -`local` profile 下提供两种开发登录方式: +- 这是当前唯一能保证“镜像拉起后直接可用”的 profile 组合 +- 用户无需先配置 GitHub OAuth,先用 mock 身份即可浏览和联调主要流程 +- 后续如果引入专用 `runtime` / `demo` profile,可以替换这层组合,但当前方案不再新增第三条部署路径 -1. **MockAuthFilter**(默认):通过 `X-Mock-User-Id` Header 模拟登录,自动创建 Session,无需真实 OAuth 流程 -2. **GitHub OAuth 测试应用**:配置 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` 后可走真实 OAuth 流程(GitHub 支持 `http://localhost` 回调) +默认可用账号: -MockAuthFilter 仅在 `local` profile 激活,通过 `@Profile("local")` 注解保证不会泄漏到其他环境。 +- `local-user` +- `local-admin` -### Docker Compose 说明 +鉴权方式: -当前推荐的本地启动入口是 `make dev-all`。Docker Compose 在当前项目里主要承担本地依赖服务启动。 +- 向后端请求携带 `X-Mock-User-Id: local-user` +- 或 `X-Mock-User-Id: local-admin` + +## 4 开发环境 + +开发入口保持不变: + +```bash +make dev-all +``` + +行为: + +- `docker-compose.yml` 启动 PostgreSQL、Redis、MinIO +- `server` 在宿主机通过 Maven Wrapper 启动 +- `web` 在宿主机通过 Vite 启动 常用命令: ```bash +make dev make dev-all +make dev-down make dev-all-down make dev-all-reset ``` -#### docker-compose.yml — 本地开发(仅依赖服务) +## 5 单机交付环境 -本地开发时前后端在宿主机运行,Docker Compose 只拉起依赖服务: - -```yaml -# docker-compose.yml(项目根目录) -services: - postgres: - image: postgres:16-alpine - ports: - - "5432:5432" - environment: - POSTGRES_DB: skillhub - POSTGRES_USER: skillhub - POSTGRES_PASSWORD: skillhub_dev - volumes: - - postgres_data:/var/lib/postgresql/data - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - - minio: - image: minio/minio:latest - ports: - - "9000:9000" - - "9001:9001" # MinIO Console - environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - command: server /data --console-address ":9001" - volumes: - - minio_data:/data - -volumes: - postgres_data: - minio_data: -``` - -生产环境文档不再提供 Compose 一键部署入口。当前仓库只保留本地开发所需的 `docker-compose.yml`,正式部署以镜像构建 + K8s 编排为准。 - -#### 前后端 Dockerfile - -后端 Dockerfile(`server/Dockerfile`): -```dockerfile -FROM maven:3.9-eclipse-temurin-21 AS build -WORKDIR /app -COPY pom.xml . -COPY skillhub-app/pom.xml skillhub-app/ -COPY skillhub-domain/pom.xml skillhub-domain/ -COPY skillhub-auth/pom.xml skillhub-auth/ -COPY skillhub-search/pom.xml skillhub-search/ -COPY skillhub-storage/pom.xml skillhub-storage/ -COPY skillhub-infra/pom.xml skillhub-infra/ -RUN mvn dependency:go-offline -B -COPY . . -RUN mvn package -DskipTests -B - -FROM eclipse-temurin:21-jre-alpine -WORKDIR /app -COPY --from=build /app/skillhub-app/target/*.jar app.jar -EXPOSE 8080 -ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"] -``` - -前端 Dockerfile(`web/Dockerfile`): -```dockerfile -FROM node:20-alpine AS build -WORKDIR /app -RUN corepack enable -COPY package.json pnpm-lock.yaml ./ -RUN pnpm install --frozen-lockfile -COPY . . -RUN pnpm build - -FROM nginx:alpine -COPY --from=build /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 -``` - -前端 Nginx 配置(`web/nginx.conf`): -```nginx -server { - listen 80; - root /usr/share/nginx/html; - index index.html; - - # SPA 路由回退 - location / { - try_files $uri $uri/ /index.html; - } - - # API 反向代理到后端 - location /api/ { - proxy_pass http://server:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # OAuth2 回调反向代理 - location /oauth2/ { - proxy_pass http://server:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } - - location /login/oauth2/ { - proxy_pass http://server:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } - - # Well-known 发现端点 - location /.well-known/ { - proxy_pass http://server:8080; - proxy_set_header Host $host; - } -} -``` - -### Spring Boot 配置文件分层 - -``` -server/skillhub-app/src/main/resources/ -├── application.yml # 公共配置(所有 profile 共享) -├── application-local.yml # 本地开发(Docker Compose 服务地址) -├── application-dev.yml # 开发环境 -├── application-staging.yml # 预发布 -└── application-prod.yml # 生产 -``` - -`application.yml`(公共配置): -```yaml -spring: - application: - name: skillhub - jpa: - open-in-view: false - hibernate: - ddl-auto: validate # 由 Flyway 管理 schema,Hibernate 仅校验 - properties: - hibernate: - dialect: org.hibernate.dialect.PostgreSQLDialect - flyway: - enabled: true - locations: classpath:db/migration - -server: - shutdown: graceful - -spring.lifecycle.timeout-per-shutdown-phase: 30s -``` - -`application-local.yml`(本地开发,对应 Docker Compose): -```yaml -spring: - datasource: - url: jdbc:postgresql://localhost:5432/skillhub - username: skillhub - password: skillhub_dev - data: - redis: - host: localhost - port: 6379 - jpa: - show-sql: true - -skillhub: - storage: - type: s3 - endpoint: http://localhost:9000 - access-key: minioadmin - secret-key: minioadmin - bucket: skillhub - region: us-east-1 - access-policy: - mode: OPEN # 本地开发默认开放准入 -``` - -`application-prod.yml`(生产环境,凭证从环境变量/K8s Secret 注入): -```yaml -spring: - datasource: - url: ${DATABASE_URL} - username: ${DATABASE_USERNAME} - password: ${DATABASE_PASSWORD} - data: - redis: - host: ${REDIS_HOST} - port: ${REDIS_PORT:6379} - jpa: - show-sql: false - -skillhub: - storage: - type: s3 - endpoint: ${S3_ENDPOINT} - access-key: ${S3_ACCESS_KEY} - secret-key: ${S3_SECRET_KEY} - bucket: ${S3_BUCKET:skillhub} - region: ${S3_REGION:us-east-1} -``` - -### 本地开发启动流程 +### 5.1 启动 ```bash -# 一键启动依赖 + 后端 + 前端 -make dev-all +cp .env.release.example .env.release +docker compose --env-file .env.release -f compose.release.yml up -d ``` -启动后可直接访问: +默认访问地址: -- Web UI: `http://localhost:3000` +- Web UI: `http://localhost` - Backend API: `http://localhost:8080` -停止: +### 5.2 关键文件 -```bash -make dev-all-down -``` +- `compose.release.yml` + - 使用发布镜像,不在用户机器上执行本地构建 + - 负责拉起 PostgreSQL、Redis、server、web + - 使用独立 Compose project name,避免与开发环境容器互相污染 +- `.env.release.example` + - 运行时变量模板 + - 包含镜像名、镜像版本、端口和数据库凭证 -如需分步启动: +### 5.3 镜像标签约定 -```bash -make dev # 仅依赖服务 -make dev-server # 仅后端 -make dev-web # 仅前端 -``` +- `edge` + - `main` 分支最新构建 + - 用于内部持续验证 +- `vX.Y.Z` + - 对应 Git tag + - 用于稳定版本交付 +- `latest` + - 仅在语义化版本 tag 发布时更新 -### Makefile 命令 +推荐: -```bash -make dev # 仅启动本地依赖服务 -make dev-all # 一键启动本地依赖 + 后端 + 前端 -make dev-down # 停止本地依赖服务 -make dev-all-down # 停止本地依赖 + 后端 + 前端 -make build # 构建后端 -make generate-api # 生成 OpenAPI 类型 -``` +- 团队内部试用:`SKILLHUB_VERSION=edge` +- 对外演示或文档引用:固定为某个 `vX.Y.Z` -## 4 配置管理 +## 6 GitHub Actions 发布流程 -- 敏感配置:K8s Secret(数据库/Redis/S3 凭证、OAuth2 Client ID/Secret) -- 非敏感配置:K8s ConfigMap(文件大小限制、Session TTL 等) +发布工作流文件:`.github/workflows/publish-images.yml` -## 5 可观测性 +触发条件: + +- push 到 `main` +- push 语义化版本 tag,例如 `v1.2.0` +- 手动 `workflow_dispatch` + +流程: + +1. 检出代码 +2. 登录 GHCR +3. 分别构建 `server/Dockerfile` 与 `web/Dockerfile` +4. 推送镜像: + - `ghcr.io/iflytek/skillhub-server` + - `ghcr.io/iflytek/skillhub-web` +5. 写入 `edge` / `vX.Y.Z` / `latest` / `sha-*` 标签 +6. 同时发布 `linux/amd64` 与 `linux/arm64` manifest,避免 Apple Silicon / ARM 主机依赖模拟层 + +## 7 配置管理 + +开发环境: + +- 本地命令与 `docker-compose.yml` +- 非敏感默认值可直接落库或写入本地配置 + +单机交付环境: + +- 使用 `.env.release` 管理 Compose 变量 +- 如果 GHCR 包保持私有,用户需要先 `docker login ghcr.io` +- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` + +## 8 可观测性 | 维度 | 方案 | |------|------| -| 日志 | JSON 格式 stdout,包含 traceId/requestId | -| 指标 | Actuator + Micrometer → Prometheus | -| 链路追踪 | 一期 requestId 透传,后续接 Jaeger/Zipkin | -| 告警 | 基于 Prometheus(5xx 率、延迟 P99、Pod 重启) | +| 健康检查 | `web/nginx-health`、`server/actuator/health` | +| 日志 | 容器 stdout / stderr | +| 指标 | Spring Boot Actuator,后续可接 Prometheus | -requestId 透传:Ingress 注入 → Spring Filter 读取放入 MDC → 日志自动携带 → 响应 Header 回传。 +## 9 数据迁移 -## 6 构建与发布 +Flyway 仍是唯一 schema 变更入口: -### CI Pipeline 构建 - -``` -代码提交 → CI Pipeline - ├── server: mvn package → JAR - └── web: pnpm build → dist/ - │ - ▼ - Docker 多阶段构建 - ├── server → eclipse-temurin:21-jre-alpine - └── web → nginx:alpine - │ - ▼ - 推送镜像 → K8s 滚动更新 -``` - -Makefile 顶层命令:`make dev`, `make dev-all`, `make dev-down`, `make dev-all-down`, `make build`, `make generate-api` - -## 7 数据库迁移 - -Flyway 管理 schema 变更: -- 脚本路径:`server/skillhub-app/src/main/resources/db/migration/` +- 路径:`server/skillhub-app/src/main/resources/db/migration/` - 命名:`V{version}__{description}.sql` -- 多 Pod 安全:Flyway 自带数据库锁 +- 启动策略:应用容器启动时自动执行迁移 diff --git a/monitoring/docker-compose.monitoring.yml b/monitoring/docker-compose.monitoring.yml new file mode 100644 index 00000000..75056f43 --- /dev/null +++ b/monitoring/docker-compose.monitoring.yml @@ -0,0 +1,17 @@ +services: + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + + grafana: + image: grafana/grafana:latest + ports: + - "3001:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + depends_on: + - prometheus diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml new file mode 100644 index 00000000..d2bf08fb --- /dev/null +++ b/monitoring/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: skillhub-backend + metrics_path: /actuator/prometheus + static_configs: + - targets: + - host.docker.internal:8080 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..1c6fcf2a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "homepage-redesign", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/scripts/check-openapi-generated.sh b/scripts/check-openapi-generated.sh new file mode 100755 index 00000000..2cc21ef5 --- /dev/null +++ b/scripts/check-openapi-generated.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SERVER_DIR="$ROOT_DIR/server" +WEB_DIR="$ROOT_DIR/web" +API_LOG="${TMPDIR:-/tmp}/skillhub-openapi-check.log" +SERVER_PID="" + +cleanup() { + if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" >/dev/null 2>&1 || true + fi + (cd "$ROOT_DIR" && docker compose down >/dev/null 2>&1) || true +} + +trap cleanup EXIT + +cd "$ROOT_DIR" +docker compose up -d --wait postgres redis + +( + cd "$SERVER_DIR" + SPRING_PROFILES_ACTIVE=local ./mvnw -pl skillhub-app spring-boot:run +) >"$API_LOG" 2>&1 & +SERVER_PID=$! + +for _ in $(seq 1 90); do + if curl -fsS "http://127.0.0.1:8080/v3/api-docs" >/dev/null 2>&1; then + break + fi + sleep 2 +done + +if ! curl -fsS "http://127.0.0.1:8080/v3/api-docs" >/dev/null 2>&1; then + echo "Backend did not expose /v3/api-docs. See $API_LOG" >&2 + exit 1 +fi + +cd "$WEB_DIR" +pnpm run generate-api + +cd "$ROOT_DIR" +git diff --exit-code -- web/src/api/generated/schema.d.ts diff --git a/scripts/dev_process.py b/scripts/dev_process.py new file mode 100644 index 00000000..8dd33f56 --- /dev/null +++ b/scripts/dev_process.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 + +import argparse +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + + +def is_running(pid: int) -> bool: + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def read_pid(pid_file: Path) -> int | None: + if not pid_file.exists(): + return None + content = pid_file.read_text(encoding="utf-8").strip() + if not content: + return None + try: + return int(content) + except ValueError: + return None + + +def write_pid(pid_file: Path, pid: int) -> None: + pid_file.parent.mkdir(parents=True, exist_ok=True) + pid_file.write_text(f"{pid}\n", encoding="utf-8") + + +def start_process(args: argparse.Namespace) -> int: + pid_file = Path(args.pid_file) + log_file = Path(args.log_file) + cwd = Path(args.cwd) + existing_pid = read_pid(pid_file) + if existing_pid and is_running(existing_pid): + print(existing_pid) + return 0 + + pid_file.unlink(missing_ok=True) + log_file.parent.mkdir(parents=True, exist_ok=True) + + command = list(args.command) + if command and command[0] == "--": + command = command[1:] + + with log_file.open("ab") as log_handle, open(os.devnull, "rb") as devnull: + process = subprocess.Popen( + command, + cwd=cwd, + stdin=devnull, + stdout=log_handle, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + + time.sleep(0.2) + if process.poll() is not None: + pid_file.unlink(missing_ok=True) + return process.returncode or 1 + + write_pid(pid_file, process.pid) + print(process.pid) + return 0 + + +def stop_process(args: argparse.Namespace) -> int: + pid_file = Path(args.pid_file) + pid = read_pid(pid_file) + if not pid: + return 0 + + if not is_running(pid): + pid_file.unlink(missing_ok=True) + return 0 + + os.kill(pid, signal.SIGTERM) + deadline = time.time() + args.timeout + while time.time() < deadline: + if not is_running(pid): + pid_file.unlink(missing_ok=True) + return 0 + time.sleep(0.2) + + os.kill(pid, signal.SIGKILL) + pid_file.unlink(missing_ok=True) + return 0 + + +def status_process(args: argparse.Namespace) -> int: + pid_file = Path(args.pid_file) + pid = read_pid(pid_file) + if not pid: + return 1 + if not is_running(pid): + pid_file.unlink(missing_ok=True) + return 1 + print(pid) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Manage detached dev processes.") + subparsers = parser.add_subparsers(dest="action", required=True) + + start_parser = subparsers.add_parser("start") + start_parser.add_argument("--pid-file", required=True) + start_parser.add_argument("--log-file", required=True) + start_parser.add_argument("--cwd", required=True) + start_parser.add_argument("command", nargs=argparse.REMAINDER) + + stop_parser = subparsers.add_parser("stop") + stop_parser.add_argument("--pid-file", required=True) + stop_parser.add_argument("--timeout", type=float, default=10.0) + + status_parser = subparsers.add_parser("status") + status_parser.add_argument("--pid-file", required=True) + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + if args.action == "start": + if not args.command: + parser.error("start requires a command") + return start_process(args) + if args.action == "stop": + return stop_process(args) + if args.action == "status": + return status_process(args) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/runtime.sh b/scripts/runtime.sh new file mode 100644 index 00000000..1d04b4d5 --- /dev/null +++ b/scripts/runtime.sh @@ -0,0 +1,174 @@ +#!/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 < Use a specific image tag, for example v0.1.0 + --home Store runtime files in a specific directory + --ref Download runtime files from a specific Git ref + --server-image Override backend image repository + --web-image 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 <&2 + echo "Usage: sh runtime.sh [up|down|clean|ps|logs|pull] [options]" >&2 + exit 1 + ;; +esac diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 00000000..add4c748 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +PASS=0 +FAIL=0 +COOKIE_JAR="$(mktemp)" +USERNAME="smoketest_$(date +%s)" +EMAIL="${USERNAME}@example.com" +PASSWORD="Smoke@2026" +NEW_PASSWORD="Smoke@2027" + +cleanup() { + rm -f "$COOKIE_JAR" +} + +trap cleanup EXIT + +check() { + local desc="$1" + local url="$2" + local expected="$3" + local status + status="$(curl --retry 3 --retry-delay 1 --max-time 10 -s -o /dev/null -w "%{http_code}" "$url" || true)" + if [[ "$status" == "$expected" ]]; then + echo "PASS: $desc (HTTP $status)" + PASS=$((PASS + 1)) + else + echo "FAIL: $desc (expected $expected, got $status)" + FAIL=$((FAIL + 1)) + fi +} + +echo "=== SkillHub Smoke Test ===" +echo "Target: $BASE_URL" +echo + +check "Health endpoint" "$BASE_URL/actuator/health" "200" +check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200" +check "Namespaces API" "$BASE_URL/api/v1/namespaces" "200" +check "Auth required" "$BASE_URL/api/v1/auth/me" "401" + +curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null +CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_JAR" | tail -n 1)" + +REGISTER_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/local/register" \ + -b "$COOKIE_JAR" \ + -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\",\"email\":\"$EMAIL\"}" || true)" +if [[ "$REGISTER_STATUS" == "200" ]]; then + echo "PASS: Register (HTTP $REGISTER_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Register (got $REGISTER_STATUS)" + FAIL=$((FAIL + 1)) +fi + +AUTH_ME_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" || true)" +if [[ "$AUTH_ME_STATUS" == "200" ]]; then + echo "PASS: Auth me with session (HTTP $AUTH_ME_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Auth me with session (got $AUTH_ME_STATUS)" + FAIL=$((FAIL + 1)) +fi + +CHANGE_PASSWORD_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/local/change-password" \ + -b "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"currentPassword\":\"$PASSWORD\",\"newPassword\":\"$NEW_PASSWORD\"}" || true)" +if [[ "$CHANGE_PASSWORD_STATUS" == "200" ]]; then + echo "PASS: Change password (HTTP $CHANGE_PASSWORD_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Change password (got $CHANGE_PASSWORD_STATUS)" + FAIL=$((FAIL + 1)) +fi + +LOGOUT_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/logout" \ + -b "$COOKIE_JAR" \ + -c "$COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" || true)" +if [[ "$LOGOUT_STATUS" == "302" || "$LOGOUT_STATUS" == "200" || "$LOGOUT_STATUS" == "204" ]]; then + echo "PASS: Logout (HTTP $LOGOUT_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Logout (got $LOGOUT_STATUS)" + FAIL=$((FAIL + 1)) +fi + +POST_LOGOUT_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" || true)" +if [[ "$POST_LOGOUT_STATUS" == "401" ]]; then + echo "PASS: Auth me after logout (HTTP $POST_LOGOUT_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Auth me after logout (got $POST_LOGOUT_STATUS)" + FAIL=$((FAIL + 1)) +fi + +echo +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi diff --git a/server/pom.xml b/server/pom.xml index 6a167777..5e9a9b25 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -14,7 +14,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.2 pom diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index 6689adc8..351a0356 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -8,7 +8,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.2 skillhub-app @@ -22,6 +22,10 @@ org.springframework.boot spring-boot-starter-actuator + + io.micrometer + micrometer-registry-prometheus + org.springdoc springdoc-openapi-starter-webmvc-ui diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java new file mode 100644 index 00000000..1879e54f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java @@ -0,0 +1,100 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Seeds default admin account for Docker one-click startup. + * Idempotent: skips if admin credential already exists. + */ +@Component +@Profile("docker") +public class DockerSeedDataRunner implements ApplicationRunner { + + private static final String ADMIN_USER_ID = "docker-admin"; + private static final String ADMIN_USERNAME = "admin"; + private static final String ADMIN_PASSWORD = "Admin@2026"; + + private static final Logger log = LoggerFactory.getLogger(DockerSeedDataRunner.class); + + private final UserAccountRepository userAccountRepository; + private final LocalCredentialRepository localCredentialRepository; + private final RoleRepository roleRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final PasswordEncoder passwordEncoder; + + public DockerSeedDataRunner(UserAccountRepository userAccountRepository, + LocalCredentialRepository localCredentialRepository, + RoleRepository roleRepository, + UserRoleBindingRepository userRoleBindingRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + PasswordEncoder passwordEncoder) { + this.userAccountRepository = userAccountRepository; + this.localCredentialRepository = localCredentialRepository; + this.roleRepository = roleRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.passwordEncoder = passwordEncoder; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + if (localCredentialRepository.existsByUsernameIgnoreCase(ADMIN_USERNAME)) { + log.info("Docker seed data already exists, skipping"); + return; + } + + // 1. Create admin user account + UserAccount admin = userAccountRepository.findById(ADMIN_USER_ID) + .orElseGet(() -> userAccountRepository.save( + new UserAccount(ADMIN_USER_ID, "Admin", "admin@skillhub.dev", null) + )); + + // 2. Create local credential (username/password) + localCredentialRepository.save( + new LocalCredential(admin.getId(), ADMIN_USERNAME, passwordEncoder.encode(ADMIN_PASSWORD)) + ); + + // 3. Assign SUPER_ADMIN role + Role superAdmin = roleRepository.findByCode("SUPER_ADMIN") + .orElseThrow(() -> new IllegalStateException("Missing built-in role: SUPER_ADMIN")); + boolean hasRole = userRoleBindingRepository.findByUserId(admin.getId()).stream() + .anyMatch(b -> b.getRole().getCode().equals("SUPER_ADMIN")); + if (!hasRole) { + userRoleBindingRepository.save(new UserRoleBinding(admin.getId(), superAdmin)); + } + + // 4. Ensure global namespace + membership + Namespace globalNs = namespaceRepository.findBySlug("global") + .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); + if (namespaceMemberRepository.findByNamespaceIdAndUserId(globalNs.getId(), admin.getId()).isEmpty()) { + namespaceMemberRepository.save(new NamespaceMember(globalNs.getId(), admin.getId(), NamespaceRole.OWNER)); + } + + log.info("Docker seed data initialized — admin account: {} / {}", ADMIN_USERNAME, ADMIN_PASSWORD); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java new file mode 100644 index 00000000..b78a871c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java @@ -0,0 +1,108 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Profile("local") +public class LocalDevDataInitializer implements ApplicationRunner { + + public static final String LOCAL_USER_ID = "local-user"; + public static final String LOCAL_ADMIN_ID = "local-admin"; + + private static final Logger log = LoggerFactory.getLogger(LocalDevDataInitializer.class); + + private final UserAccountRepository userAccountRepository; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final RoleRepository roleRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + + public LocalDevDataInitializer(UserAccountRepository userAccountRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + RoleRepository roleRepository, + UserRoleBindingRepository userRoleBindingRepository) { + this.userAccountRepository = userAccountRepository; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.roleRepository = roleRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + UserAccount localUser = ensureUser( + LOCAL_USER_ID, + "Local Developer", + "local-user@example.test" + ); + UserAccount localAdmin = ensureUser( + LOCAL_ADMIN_ID, + "Local Admin", + "local-admin@example.test" + ); + + Namespace globalNamespace = namespaceRepository.findBySlug("global") + .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); + + ensureMembership(globalNamespace.getId(), localUser.getId(), NamespaceRole.OWNER); + ensureMembership(globalNamespace.getId(), localAdmin.getId(), NamespaceRole.OWNER); + ensureRole(localAdmin.getId(), "SUPER_ADMIN"); + + log.info("Local dev accounts ready: {} / {}", LOCAL_USER_ID, LOCAL_ADMIN_ID); + } + + private UserAccount ensureUser(String userId, String displayName, String email) { + return userAccountRepository.findById(userId) + .map(existing -> { + existing.setDisplayName(displayName); + existing.setEmail(email); + existing.setStatus(UserStatus.ACTIVE); + return userAccountRepository.save(existing); + }) + .orElseGet(() -> userAccountRepository.save( + new UserAccount(userId, displayName, email, null) + )); + } + + private void ensureMembership(Long namespaceId, String userId, NamespaceRole role) { + NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) + .orElseGet(() -> new NamespaceMember(namespaceId, userId, role)); + if (member.getRole() != role) { + member.setRole(role); + } + namespaceMemberRepository.save(member); + } + + private void ensureRole(String userId, String roleCode) { + boolean exists = userRoleBindingRepository.findByUserId(userId).stream() + .map(binding -> binding.getRole().getCode()) + .anyMatch(roleCode::equals); + if (exists) { + return; + } + + Role role = roleRepository.findByCode(roleCode) + .orElseThrow(() -> new IllegalStateException("Missing built-in role: " + roleCode)); + userRoleBindingRepository.save(new UserRoleBinding(userId, role)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index de278d66..32efb628 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -1,15 +1,28 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.compat.dto.ClawHubPublishResponse; import com.iflytek.skillhub.compat.dto.ClawHubSkillItem; import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse; import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse; import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse; +import com.iflytek.skillhub.controller.support.ZipPackageExtractor; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.service.SkillSearchAppService; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.List; import java.util.Map; @@ -19,10 +32,23 @@ public class ClawHubCompatController { private final CanonicalSlugMapper mapper; private final SkillSearchAppService skillSearchAppService; + private final SkillQueryService skillQueryService; + private final SkillPublishService skillPublishService; + private final ZipPackageExtractor zipPackageExtractor; + private final AuditLogService auditLogService; - public ClawHubCompatController(CanonicalSlugMapper mapper, SkillSearchAppService skillSearchAppService) { + public ClawHubCompatController(CanonicalSlugMapper mapper, + SkillSearchAppService skillSearchAppService, + SkillQueryService skillQueryService, + SkillPublishService skillPublishService, + ZipPackageExtractor zipPackageExtractor, + AuditLogService auditLogService) { this.mapper = mapper; this.skillSearchAppService = skillSearchAppService; + this.skillQueryService = skillQueryService; + this.skillPublishService = skillPublishService; + this.zipPackageExtractor = zipPackageExtractor; + this.auditLogService = auditLogService; } @GetMapping("/search") @@ -56,12 +82,63 @@ public class ClawHubCompatController { @GetMapping("/resolve/{canonicalSlug}") public ClawHubResolveResponse resolve( @PathVariable String canonicalSlug, - @RequestParam(defaultValue = "latest") String version) { + @RequestParam(defaultValue = "latest") String version, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( + coord.namespace(), + coord.slug(), + "latest".equals(version) ? null : version, + "latest".equals(version) ? "latest" : null, + null, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); return new ClawHubResolveResponse( canonicalSlug, - version, - "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" + resolved.version(), + resolved.downloadUrl() + ); + } + + @GetMapping("/download/{canonicalSlug}") + public ResponseEntity download(@PathVariable String canonicalSlug, + @RequestParam(defaultValue = "latest") String version) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + String location = "latest".equals(version) + ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" + : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, location) + .build(); + } + + @PostMapping("/publish") + public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file, + @RequestParam("namespace") String namespace, + @RequestAttribute("userId") String userId, + HttpServletRequest request) throws IOException { + SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( + namespace, + zipPackageExtractor.extract(file), + userId, + SkillVisibility.PUBLIC + ); + auditLogService.record( + userId, + "COMPAT_PUBLISH", + "SKILL_VERSION", + result.version().getId(), + MDC.get("requestId"), + request.getRemoteAddr(), + request.getHeader("User-Agent"), + "{\"namespace\":\"" + namespace + "\"}" + ); + return new ClawHubPublishResponse( + mapper.toCanonical(namespace, result.slug()), + result.version().getVersion(), + result.version().getStatus().name() ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java index 668a3b44..92705731 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java @@ -15,8 +15,15 @@ public class DomainBeanConfig { } @Bean - public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser) { - return new SkillPackageValidator(skillMetadataParser); + public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser, + SkillPublishProperties skillPublishProperties) { + return new SkillPackageValidator( + skillMetadataParser, + skillPublishProperties.getMaxFileCount(), + skillPublishProperties.getMaxSingleFileSize(), + skillPublishProperties.getMaxPackageSize(), + skillPublishProperties.getAllowedFileExtensions() + ); } @Bean diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java index e45f3c7f..e79a8ea6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java @@ -17,7 +17,7 @@ public class OpenApiConfig { .info(new Info() .title("SkillHub API") .description("Skills Registry Platform") - .version("0.1.0")) + .version("0.1.0-beta.2")) .servers(List.of( new Server().url("http://localhost:8080").description("Local development") )); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java new file mode 100644 index 00000000..7d5fadd7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashSet; +import java.util.Set; + +@Component +@ConfigurationProperties(prefix = "skillhub.publish") +public class SkillPublishProperties { + + private int maxFileCount = 100; + private long maxSingleFileSize = 1024 * 1024; + private long maxPackageSize = 100 * 1024 * 1024; + private Set allowedFileExtensions = new LinkedHashSet<>(Set.of( + ".md", ".txt", ".json", ".yaml", ".yml", + ".js", ".ts", ".py", ".sh", + ".png", ".jpg", ".svg" + )); + + public int getMaxFileCount() { + return maxFileCount; + } + + public void setMaxFileCount(int maxFileCount) { + this.maxFileCount = maxFileCount; + } + + public long getMaxSingleFileSize() { + return maxSingleFileSize; + } + + public void setMaxSingleFileSize(long maxSingleFileSize) { + this.maxSingleFileSize = maxSingleFileSize; + } + + public long getMaxPackageSize() { + return maxPackageSize; + } + + public void setMaxPackageSize(long maxPackageSize) { + this.maxPackageSize = maxPackageSize; + } + + public Set getAllowedFileExtensions() { + return allowedFileExtensions; + } + + public void setAllowedFileExtensions(Set allowedFileExtensions) { + this.allowedFileExtensions = new LinkedHashSet<>(allowedFileExtensions); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java new file mode 100644 index 00000000..7e812f96 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java @@ -0,0 +1,71 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.merge.AccountMergeService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.MergeInitiateRequest; +import com.iflytek.skillhub.dto.MergeInitiateResponse; +import com.iflytek.skillhub.dto.MergeVerifyRequest; +import com.iflytek.skillhub.dto.MessageResponse; +import com.iflytek.skillhub.exception.UnauthorizedException; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/account/merge") +public class AccountMergeController extends BaseApiController { + + private final AccountMergeService accountMergeService; + + public AccountMergeController(ApiResponseFactory responseFactory, + AccountMergeService accountMergeService) { + super(responseFactory); + this.accountMergeService = accountMergeService; + } + + @PostMapping("/initiate") + public ApiResponse initiate(@AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody MergeInitiateRequest request) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + var result = accountMergeService.initiate(principal.userId(), request.secondaryIdentifier()); + return ok("response.success.created", new MergeInitiateResponse( + result.mergeRequestId(), + result.secondaryUserId(), + result.verificationToken(), + result.expiresAt().toString() + )); + } + + @PostMapping("/verify") + public ApiResponse verify(@AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody MergeVerifyRequest request) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + accountMergeService.verify( + principal.userId(), + request.mergeRequestId(), + request.verificationToken() + ); + return ok("response.success.updated", new MessageResponse("Account merge verified")); + } + + @PostMapping("/confirm") + public ApiResponse confirm(@AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody ConfirmMergeRequest request) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + accountMergeService.confirm(principal.userId(), request.mergeRequestId()); + return ok("response.success.updated", new MessageResponse("Account merge completed")); + } + + public record ConfirmMergeRequest(@jakarta.validation.constraints.NotNull Long mergeRequestId) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java index 5363eab7..13d8071a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java @@ -2,12 +2,15 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; import com.iflytek.skillhub.domain.skill.validation.ValidationResult; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.CliWhoamiResponse; +import com.iflytek.skillhub.dto.ResolveVersionResponse; import com.iflytek.skillhub.dto.SkillCheckResponse; import org.springframework.security.core.annotation.AuthenticationPrincipal; import com.iflytek.skillhub.exception.UnauthorizedException; @@ -16,6 +19,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.util.List; +import java.util.Map; @RestController @RequestMapping("/api/v1/cli") @@ -23,13 +27,16 @@ public class CliController extends BaseApiController { private final SkillPackageValidator skillPackageValidator; private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; + private final SkillQueryService skillQueryService; public CliController(ApiResponseFactory responseFactory, SkillPackageValidator skillPackageValidator, - SkillPackageArchiveExtractor skillPackageArchiveExtractor) { + SkillPackageArchiveExtractor skillPackageArchiveExtractor, + SkillQueryService skillQueryService) { super(responseFactory); this.skillPackageValidator = skillPackageValidator; this.skillPackageArchiveExtractor = skillPackageArchiveExtractor; + this.skillQueryService = skillQueryService; } @GetMapping("/whoami") @@ -66,4 +73,33 @@ public class CliController extends BaseApiController { return ok("response.success.validated", response); } + + @GetMapping("/resolve/{namespace}/{slug}") + public ApiResponse resolve(@PathVariable String namespace, + @PathVariable String slug, + @RequestParam(required = false) String version, + @RequestParam(required = false) String tag, + @RequestParam(required = false) String hash, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( + namespace, + slug, + version, + tag, + hash, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); + return ok("response.success.read", new ResolveVersionResponse( + resolved.skillId(), + resolved.namespace(), + resolved.slug(), + resolved.version(), + resolved.versionId(), + resolved.fingerprint(), + resolved.matched(), + resolved.downloadUrl() + )); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java index 13eca9da..aec99cf1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java @@ -2,9 +2,12 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.MessageResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -16,18 +19,33 @@ import org.springframework.web.bind.annotation.RestController; public class DeviceAuthWebController extends BaseApiController { private final DeviceAuthService deviceAuthService; + private final AuditLogService auditLogService; - public DeviceAuthWebController(ApiResponseFactory responseFactory, DeviceAuthService deviceAuthService) { + public DeviceAuthWebController(ApiResponseFactory responseFactory, + DeviceAuthService deviceAuthService, + AuditLogService auditLogService) { super(responseFactory); this.deviceAuthService = deviceAuthService; + this.auditLogService = auditLogService; } @PostMapping("/authorize") public ApiResponse authorizeDevice( @RequestBody AuthorizeRequest request, - @AuthenticationPrincipal PlatformPrincipal principal + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest ) { deviceAuthService.authorizeDeviceCode(request.userCode(), principal.userId()); + auditLogService.record( + principal.userId(), + "DEVICE_AUTHORIZE", + "DEVICE_CODE", + null, + MDC.get("requestId"), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + "{\"userCode\":\"" + request.userCode() + "\"}" + ); return ok("response.success.updated", new MessageResponse("Device authorized successfully")); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java new file mode 100644 index 00000000..59494ab5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -0,0 +1,87 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.AuthMeResponse; +import com.iflytek.skillhub.dto.ChangePasswordRequest; +import com.iflytek.skillhub.dto.LocalLoginRequest; +import com.iflytek.skillhub.dto.LocalRegisterRequest; +import com.iflytek.skillhub.exception.UnauthorizedException; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/auth/local") +public class LocalAuthController extends BaseApiController { + + private final LocalAuthService localAuthService; + private final SkillHubMetrics skillHubMetrics; + + public LocalAuthController(ApiResponseFactory responseFactory, + LocalAuthService localAuthService, + SkillHubMetrics skillHubMetrics) { + super(responseFactory); + this.localAuthService = localAuthService; + this.skillHubMetrics = skillHubMetrics; + } + + @PostMapping("/register") + public ApiResponse register(@Valid @RequestBody LocalRegisterRequest request, + HttpServletRequest httpRequest) { + PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email()); + skillHubMetrics.incrementUserRegister(); + establishSession(principal, httpRequest); + return ok("response.success.created", AuthMeResponse.from(principal)); + } + + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody LocalLoginRequest request, + HttpServletRequest httpRequest) { + PlatformPrincipal principal; + try { + principal = localAuthService.login(request.username(), request.password()); + } catch (RuntimeException ex) { + skillHubMetrics.recordLocalLogin(false); + throw ex; + } + skillHubMetrics.recordLocalLogin(true); + establishSession(principal, httpRequest); + return ok("response.success.read", AuthMeResponse.from(principal)); + } + + @PostMapping("/change-password") + public ApiResponse changePassword(@AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody ChangePasswordRequest request) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + localAuthService.changePassword(principal.userId(), request.currentPassword(), request.newPassword()); + return ok("response.success.updated", null); + } + + private void establishSession(PlatformPrincipal principal, HttpServletRequest request) { + var authorities = principal.platformRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + var authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + request.getSession(true).setAttribute("platformPrincipal", principal); + request.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java new file mode 100644 index 00000000..25e3d053 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java @@ -0,0 +1,76 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.AdminSkillActionRequest; +import com.iflytek.skillhub.dto.AdminSkillMutationResponse; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/skills") +public class AdminSkillController extends BaseApiController { + + private final SkillGovernanceService skillGovernanceService; + + public AdminSkillController(ApiResponseFactory responseFactory, + SkillGovernanceService skillGovernanceService) { + super(responseFactory); + this.skillGovernanceService = skillGovernanceService; + } + + @PostMapping("/{skillId}/hide") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + public ApiResponse hideSkill(@PathVariable Long skillId, + @RequestBody(required = false) AdminSkillActionRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + var skill = skillGovernanceService.hideSkill( + skillId, + principal.userId(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + request != null ? request.reason() : null + ); + return ok("response.success.updated", new AdminSkillMutationResponse(skillId, null, "HIDE", skill.getStatus().name())); + } + + @PostMapping("/{skillId}/unhide") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + public ApiResponse unhideSkill(@PathVariable Long skillId, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + var skill = skillGovernanceService.unhideSkill( + skillId, + principal.userId(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", new AdminSkillMutationResponse(skillId, null, "UNHIDE", skill.getStatus().name())); + } + + @PostMapping("/versions/{versionId}/yank") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + public ApiResponse yankVersion(@PathVariable Long versionId, + @RequestBody(required = false) AdminSkillActionRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + var version = skillGovernanceService.yankVersion( + versionId, + principal.userId(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + request != null ? request.reason() : null + ); + return ok("response.success.updated", new AdminSkillMutationResponse(version.getSkillId(), versionId, "YANK", version.getStatus().name())); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java index bffd32c5..850ecc26 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java @@ -54,4 +54,22 @@ public class UserManagementController extends BaseApiController { @Valid @RequestBody AdminUserStatusUpdateRequest request) { return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, request.status())); } + + @PostMapping("/{userId}/approve") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse approveUser(@PathVariable String userId) { + return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE")); + } + + @PostMapping("/{userId}/disable") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse disableUser(@PathVariable String userId) { + return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "DISABLED")); + } + + @PostMapping("/{userId}/enable") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse enableUser(@PathVariable String userId) { + return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE")); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java index 953ee4f8..3bc2690d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.cli; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; @@ -9,7 +10,10 @@ import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PublishResponse; +import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -22,13 +26,19 @@ public class CliPublishController extends BaseApiController { private final SkillPublishService skillPublishService; private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; + private final SkillHubMetrics skillHubMetrics; + private final AuditLogService auditLogService; public CliPublishController(SkillPublishService skillPublishService, SkillPackageArchiveExtractor skillPackageArchiveExtractor, - ApiResponseFactory responseFactory) { + ApiResponseFactory responseFactory, + SkillHubMetrics skillHubMetrics, + AuditLogService auditLogService) { super(responseFactory); this.skillPublishService = skillPublishService; this.skillPackageArchiveExtractor = skillPackageArchiveExtractor; + this.skillHubMetrics = skillHubMetrics; + this.auditLogService = auditLogService; } @PostMapping("/publish") @@ -37,7 +47,8 @@ public class CliPublishController extends BaseApiController { @RequestParam("file") MultipartFile file, @RequestParam("namespace") String namespace, @RequestParam("visibility") String visibility, - @RequestAttribute("userId") String userId) throws IOException { + @RequestAttribute("userId") String userId, + HttpServletRequest request) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); @@ -64,6 +75,17 @@ public class CliPublishController extends BaseApiController { publishResult.version().getFileCount(), publishResult.version().getTotalSize() ); + skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name()); + auditLogService.record( + userId, + "CLI_PUBLISH", + "SKILL_VERSION", + publishResult.version().getId(), + MDC.get("requestId"), + request.getRemoteAddr(), + request.getHeader("User-Agent"), + "{\"namespace\":\"" + namespace + "\"}" + ); return ok("response.success.published", response); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java index 9b4c8f75..630cc91c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java @@ -34,4 +34,14 @@ public class MeController extends BaseApiController { return ok("response.success.read", mySkillAppService.listMySkills(principal.userId())); } + + @GetMapping("/stars") + public ApiResponse> listMyStars( + @AuthenticationPrincipal PlatformPrincipal principal) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + + return ok("response.success.read", mySkillAppService.listMyStars(principal.userId())); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index 66d276d0..14628e63 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java @@ -2,25 +2,40 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.audit.AuditLogService; 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.ReviewPermissionChecker; 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; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; -import com.iflytek.skillhub.dto.*; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.PromotionActionRequest; +import com.iflytek.skillhub.dto.PromotionRequestDto; +import com.iflytek.skillhub.dto.PromotionResponseDto; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import java.util.Map; import java.util.Set; @@ -36,7 +51,7 @@ public class PromotionController extends BaseApiController { private final NamespaceRepository namespaceRepository; private final UserAccountRepository userAccountRepository; private final RbacService rbacService; - private final ReviewPermissionChecker permissionChecker; + private final AuditLogService auditLogService; public PromotionController(PromotionService promotionService, PromotionRequestRepository promotionRequestRepository, @@ -45,7 +60,7 @@ public class PromotionController extends BaseApiController { NamespaceRepository namespaceRepository, UserAccountRepository userAccountRepository, RbacService rbacService, - ReviewPermissionChecker permissionChecker, + AuditLogService auditLogService, ApiResponseFactory responseFactory) { super(responseFactory); this.promotionService = promotionService; @@ -55,50 +70,74 @@ public class PromotionController extends BaseApiController { this.namespaceRepository = namespaceRepository; this.userAccountRepository = userAccountRepository; this.rbacService = rbacService; - this.permissionChecker = permissionChecker; + this.auditLogService = auditLogService; } @PostMapping - public ApiResponse submitPromotion( - @RequestBody PromotionRequestDto request, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + public ApiResponse submitPromotion(@RequestBody PromotionRequestDto request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { PromotionRequest promotion = promotionService.submitPromotion( - request.sourceSkillId(), request.sourceVersionId(), - request.targetNamespaceId(), userId, - userNsRoles != null ? userNsRoles : Map.of()); - return ok("response.success.create", toResponse(promotion)); + request.sourceSkillId(), + request.sourceVersionId(), + request.targetNamespaceId(), + userId, + userNsRoles != null ? userNsRoles : Map.of(), + rbacService.getUserRoleCodes(userId) + ); + recordAudit( + "PROMOTION_SUBMIT", + userId, + promotion.getId(), + httpRequest, + "{\"sourceSkillId\":" + request.sourceSkillId() + ",\"sourceVersionId\":" + request.sourceVersionId() + "}" + ); + return ok("response.success.created", toResponse(promotion)); } @PostMapping("/{id}/approve") - public ApiResponse approvePromotion( - @PathVariable Long id, - @RequestBody(required = false) PromotionActionRequest request, - @RequestAttribute("userId") String userId) { + public ApiResponse approvePromotion(@PathVariable Long id, + @RequestBody(required = false) PromotionActionRequest request, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; - Set platformRoles = rbacService.getUserRoleCodes(userId); - PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, platformRoles); + PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, rbacService.getUserRoleCodes(userId)); + recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(promotion)); } @PostMapping("/{id}/reject") - public ApiResponse rejectPromotion( - @PathVariable Long id, - @RequestBody(required = false) PromotionActionRequest request, - @RequestAttribute("userId") String userId) { + public ApiResponse rejectPromotion(@PathVariable Long id, + @RequestBody(required = false) PromotionActionRequest request, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; - Set platformRoles = rbacService.getUserRoleCodes(userId); - PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, platformRoles); + PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, rbacService.getUserRoleCodes(userId)); + recordAudit("PROMOTION_REJECT", userId, promotion.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(promotion)); } - @GetMapping("/pending") - public ApiResponse> listPendingPromotions( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") String userId) { + @GetMapping + public ApiResponse> listPromotions(@RequestParam(defaultValue = "PENDING") String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId) { Set platformRoles = rbacService.getUserRoleCodes(userId); - if (!permissionChecker.canListPendingPromotions(platformRoles)) { + if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) { + throw new DomainForbiddenException("promotion.no_permission"); + } + ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); + Page requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + return ok("response.success.read", PageResponse.from(requests.map(this::toResponse))); + } + + @GetMapping("/pending") + public ApiResponse> listPendingPromotions(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId) { + Set platformRoles = rbacService.getUserRoleCodes(userId); + if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) { throw new DomainForbiddenException("promotion.no_permission"); } Page requests = promotionRequestRepository.findByStatus( @@ -107,47 +146,73 @@ public class PromotionController extends BaseApiController { } @GetMapping("/{id}") - public ApiResponse getPromotionDetail( - @PathVariable Long id, - @RequestAttribute("userId") String userId) { - PromotionRequest promotion = promotionRequestRepository.findById(id).orElseThrow(); - Set platformRoles = rbacService.getUserRoleCodes(userId); - if (!permissionChecker.canReadPromotion(promotion, userId, platformRoles)) { + public ApiResponse getPromotionDetail(@PathVariable Long id, + @RequestAttribute("userId") String userId) { + PromotionRequest promotion = promotionRequestRepository.findById(id) + .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", id)); + if (!promotionService.canViewPromotion(promotion, userId, rbacService.getUserRoleCodes(userId))) { throw new DomainForbiddenException("promotion.no_permission"); } return ok("response.success.read", toResponse(promotion)); } - private PromotionResponseDto toResponse(PromotionRequest req) { - Skill sourceSkill = skillRepository.findById(req.getSourceSkillId()).orElseThrow(); - SkillVersion sourceVersion = skillVersionRepository.findById(req.getSourceVersionId()).orElseThrow(); - Namespace sourceNs = namespaceRepository.findById(sourceSkill.getNamespaceId()).orElseThrow(); - Namespace targetNs = namespaceRepository.findById(req.getTargetNamespaceId()).orElseThrow(); + private PromotionResponseDto toResponse(PromotionRequest request) { + Skill sourceSkill = skillRepository.findById(request.getSourceSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId())); + SkillVersion sourceVersion = skillVersionRepository.findById(request.getSourceVersionId()) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", request.getSourceVersionId())); + Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); + Namespace targetNamespace = namespaceRepository.findById(request.getTargetNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", request.getTargetNamespaceId())); - String submittedByName = userAccountRepository.findById(req.getSubmittedBy()) - .map(UserAccount::getDisplayName).orElse(null); - - String reviewedByName = req.getReviewedBy() != null - ? userAccountRepository.findById(req.getReviewedBy()) - .map(UserAccount::getDisplayName).orElse(null) + String submittedByName = userAccountRepository.findById(request.getSubmittedBy()) + .map(UserAccount::getDisplayName) + .orElse(null); + String reviewedByName = request.getReviewedBy() != null + ? userAccountRepository.findById(request.getReviewedBy()).map(UserAccount::getDisplayName).orElse(null) : null; return new PromotionResponseDto( - req.getId(), - req.getSourceSkillId(), - sourceNs.getSlug(), + request.getId(), + request.getSourceSkillId(), + sourceNamespace.getSlug(), sourceSkill.getSlug(), sourceVersion.getVersion(), - targetNs.getSlug(), - req.getTargetSkillId(), - req.getStatus().name(), - req.getSubmittedBy(), + targetNamespace.getSlug(), + request.getTargetSkillId(), + request.getStatus().name(), + request.getSubmittedBy(), submittedByName, - req.getReviewedBy(), + request.getReviewedBy(), reviewedByName, - req.getReviewComment(), - req.getSubmittedAt(), - req.getReviewedAt() + request.getReviewComment(), + request.getSubmittedAt(), + request.getReviewedAt() ); } + + private void recordAudit(String action, + String userId, + Long targetId, + HttpServletRequest httpRequest, + String detailJson) { + auditLogService.record( + userId, + action, + "PROMOTION_REQUEST", + targetId, + MDC.get("requestId"), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + detailJson + ); + } + + private String detailWithComment(String comment) { + if (comment == null || comment.isBlank()) { + return null; + } + return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java index 1b92da5b..61a13e40 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java @@ -2,25 +2,41 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.audit.AuditLogService; 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.ReviewService; -import com.iflytek.skillhub.domain.review.ReviewPermissionChecker; 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; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; -import com.iflytek.skillhub.dto.*; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.ReviewActionRequest; +import com.iflytek.skillhub.dto.ReviewTaskRequest; +import com.iflytek.skillhub.dto.ReviewTaskResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import java.util.Map; import java.util.Set; @@ -36,7 +52,7 @@ public class ReviewController extends BaseApiController { private final NamespaceRepository namespaceRepository; private final UserAccountRepository userAccountRepository; private final RbacService rbacService; - private final ReviewPermissionChecker permissionChecker; + private final AuditLogService auditLogService; public ReviewController(ReviewService reviewService, ReviewTaskRepository reviewTaskRepository, @@ -45,7 +61,7 @@ public class ReviewController extends BaseApiController { NamespaceRepository namespaceRepository, UserAccountRepository userAccountRepository, RbacService rbacService, - ReviewPermissionChecker permissionChecker, + AuditLogService auditLogService, ApiResponseFactory responseFactory) { super(responseFactory); this.reviewService = reviewService; @@ -55,71 +71,125 @@ public class ReviewController extends BaseApiController { this.namespaceRepository = namespaceRepository; this.userAccountRepository = userAccountRepository; this.rbacService = rbacService; - this.permissionChecker = permissionChecker; + this.auditLogService = auditLogService; } @PostMapping - public ApiResponse submitReview( - @RequestBody ReviewTaskRequest request, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + public ApiResponse submitReview(@RequestBody ReviewTaskRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { ReviewTask task = reviewService.submitReview( request.skillVersionId(), userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + rbacService.getUserRoleCodes(userId) ); - return ok("response.success.create", toResponse(task)); + recordAudit("REVIEW_SUBMIT", userId, task.getId(), httpRequest, "{\"skillVersionId\":" + request.skillVersionId() + "}"); + return ok("response.success.created", toResponse(task)); } @PostMapping("/{id}/approve") - public ApiResponse approveReview( - @PathVariable Long id, - @RequestBody(required = false) ReviewActionRequest request, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + public ApiResponse approveReview(@PathVariable Long id, + @RequestBody(required = false) ReviewActionRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; - Set platformRoles = rbacService.getUserRoleCodes(userId); - ReviewTask task = reviewService.approveReview(id, userId, comment, - userNsRoles != null ? userNsRoles : Map.of(), platformRoles); + ReviewTask task = reviewService.approveReview( + id, + userId, + comment, + userNsRoles != null ? userNsRoles : Map.of(), + rbacService.getUserRoleCodes(userId) + ); + recordAudit("REVIEW_APPROVE", userId, task.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(task)); } @PostMapping("/{id}/reject") - public ApiResponse rejectReview( - @PathVariable Long id, - @RequestBody(required = false) ReviewActionRequest request, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + public ApiResponse rejectReview(@PathVariable Long id, + @RequestBody(required = false) ReviewActionRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; - Set platformRoles = rbacService.getUserRoleCodes(userId); - ReviewTask task = reviewService.rejectReview(id, userId, comment, - userNsRoles != null ? userNsRoles : Map.of(), platformRoles); + ReviewTask task = reviewService.rejectReview( + id, + userId, + comment, + userNsRoles != null ? userNsRoles : Map.of(), + rbacService.getUserRoleCodes(userId) + ); + recordAudit("REVIEW_REJECT", userId, task.getId(), httpRequest, detailWithComment(comment)); return ok("response.success.updated", toResponse(task)); } @PostMapping("/{id}/withdraw") - public ApiResponse withdrawReview( - @PathVariable Long id, - @RequestAttribute("userId") String userId) { - ReviewTask task = reviewTaskRepository.findById(id).orElseThrow(); + public ApiResponse withdrawReview(@PathVariable Long id, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + ReviewTask task = reviewTaskRepository.findById(id) + .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id)); reviewService.withdrawReview(task.getSkillVersionId(), userId); + recordAudit("REVIEW_WITHDRAW", userId, id, httpRequest, "{\"skillVersionId\":" + task.getSkillVersionId() + "}"); return ok("response.success.updated", null); } + @GetMapping + public ApiResponse> listReviews(@RequestParam String status, + @RequestParam(required = false) Long namespaceId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); + Map namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); + + Page tasks; + if (namespaceId != null) { + Namespace namespace = namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); + ReviewTask probe = new ReviewTask(0L, namespaceId, userId); + if (!reviewService.canReviewNamespace( + probe, + userId, + namespace.getType(), + namespaceRoles, + rbacService.getUserRoleCodes(userId))) { + throw new DomainForbiddenException("review.no_permission"); + } + tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, PageRequest.of(page, size)); + } else { + tasks = reviewTaskRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + } + + java.util.List visibleItems = tasks.getContent().stream() + .filter(task -> canViewReview(task, userId, namespaceRoles)) + .map(this::toResponse) + .toList(); + + return ok( + "response.success.read", + PageResponse.from(new PageImpl<>(visibleItems, tasks.getPageable(), visibleItems.size())) + ); + } + @GetMapping("/pending") - public ApiResponse> listPendingReviews( - @RequestParam Long namespaceId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - Namespace namespace = namespaceRepository.findById(namespaceId).orElseThrow(); - Set platformRoles = rbacService.getUserRoleCodes(userId); - if (!permissionChecker.canManageNamespaceReviews( - namespaceId, + public ApiResponse> listPendingReviews(@RequestParam Long namespaceId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + Namespace namespace = namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); + ReviewTask probe = new ReviewTask(0L, namespaceId, userId); + if (!reviewService.canReviewNamespace( + probe, + userId, namespace.getType(), userNsRoles != null ? userNsRoles : Map.of(), - platformRoles)) { + rbacService.getUserRoleCodes(userId))) { throw new DomainForbiddenException("review.no_permission"); } @@ -129,53 +199,54 @@ public class ReviewController extends BaseApiController { } @GetMapping("/my-submissions") - public ApiResponse> listMySubmissions( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") String userId) { + public ApiResponse> listMySubmissions(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId) { Page tasks = reviewTaskRepository.findBySubmittedByAndStatus( userId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); } @GetMapping("/{id}") - public ApiResponse getReviewDetail( - @PathVariable Long id, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - ReviewTask task = reviewTaskRepository.findById(id).orElseThrow(); - Namespace namespace = namespaceRepository.findById(task.getNamespaceId()).orElseThrow(); - Set platformRoles = rbacService.getUserRoleCodes(userId); - if (!permissionChecker.canReadReview( + public ApiResponse getReviewDetail(@PathVariable Long id, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + ReviewTask task = reviewTaskRepository.findById(id) + .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id)); + Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); + if (!reviewService.canViewReview( task, userId, namespace.getType(), userNsRoles != null ? userNsRoles : Map.of(), - platformRoles)) { + rbacService.getUserRoleCodes(userId))) { throw new DomainForbiddenException("review.no_permission"); } return ok("response.success.read", toResponse(task)); } private ReviewTaskResponse toResponse(ReviewTask task) { - SkillVersion sv = skillVersionRepository.findById(task.getSkillVersionId()).orElseThrow(); - Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow(); - Namespace ns = namespaceRepository.findById(skill.getNamespaceId()).orElseThrow(); + SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); + Skill skill = skillRepository.findById(skillVersion.getSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); String submittedByName = userAccountRepository.findById(task.getSubmittedBy()) - .map(UserAccount::getDisplayName).orElse(null); - + .map(UserAccount::getDisplayName) + .orElse(null); String reviewedByName = task.getReviewedBy() != null - ? userAccountRepository.findById(task.getReviewedBy()) - .map(UserAccount::getDisplayName).orElse(null) + ? userAccountRepository.findById(task.getReviewedBy()).map(UserAccount::getDisplayName).orElse(null) : null; return new ReviewTaskResponse( task.getId(), task.getSkillVersionId(), - ns.getSlug(), + namespace.getSlug(), skill.getSlug(), - sv.getVersion(), + skillVersion.getVersion(), task.getStatus().name(), task.getSubmittedBy(), submittedByName, @@ -186,4 +257,40 @@ public class ReviewController extends BaseApiController { task.getReviewedAt() ); } + + private boolean canViewReview(ReviewTask task, String userId, Map namespaceRoles) { + Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); + return reviewService.canViewReview( + task, + userId, + namespace.getType(), + namespaceRoles, + rbacService.getUserRoleCodes(userId) + ); + } + + private void recordAudit(String action, + String userId, + Long targetId, + HttpServletRequest httpRequest, + String detailJson) { + auditLogService.record( + userId, + action, + "REVIEW_TASK", + targetId, + MDC.get("requestId"), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + detailJson + ); + } + + private String detailWithComment(String comment) { + if (comment == null || comment.isBlank()) { + return null; + } + return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 2fba90df..e265ce57 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -63,6 +64,9 @@ public class SkillController extends BaseApiController { detail.status(), detail.downloadCount(), detail.starCount(), + detail.ratingAvg(), + detail.ratingCount(), + detail.hidden(), detail.latestVersion(), namespace ); @@ -75,10 +79,16 @@ public class SkillController extends BaseApiController { @PathVariable String namespace, @PathVariable String slug, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { + @RequestParam(defaultValue = "20") int size, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { Page versions = skillQueryService.listVersions( - namespace, slug, PageRequest.of(page, size)); + namespace, + slug, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + PageRequest.of(page, size)); PageResponse response = PageResponse.from(versions.map(v -> new SkillVersionResponse( v.getId(), @@ -272,11 +282,7 @@ public class SkillController extends BaseApiController { SkillDownloadService.DownloadResult result = skillDownloadService.downloadLatest( namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of()); - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"") - .contentType(MediaType.parseMediaType(result.contentType())) - .contentLength(result.contentLength()) - .body(new InputStreamResource(result.content())); + return buildDownloadResponse(result); } @GetMapping("/{namespace}/{slug}/versions/{version}/download") @@ -291,11 +297,7 @@ public class SkillController extends BaseApiController { SkillDownloadService.DownloadResult result = skillDownloadService.downloadVersion( namespace, slug, version, userId, userNsRoles != null ? userNsRoles : Map.of()); - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"") - .contentType(MediaType.parseMediaType(result.contentType())) - .contentLength(result.contentLength()) - .body(new InputStreamResource(result.content())); + return buildDownloadResponse(result); } @GetMapping("/{namespace}/{slug}/tags/{tagName}/download") @@ -310,6 +312,16 @@ public class SkillController extends BaseApiController { SkillDownloadService.DownloadResult result = skillDownloadService.downloadByTag( namespace, slug, tagName, userId, userNsRoles != null ? userNsRoles : Map.of()); + return buildDownloadResponse(result); + } + + private ResponseEntity buildDownloadResponse(SkillDownloadService.DownloadResult result) { + if (result.presignedUrl() != null) { + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, result.presignedUrl()) + .build(); + } + return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"") .contentType(MediaType.parseMediaType(result.contentType())) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index 7d96a749..f8b14665 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -9,6 +9,7 @@ import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PublishResponse; +import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -22,13 +23,16 @@ public class SkillPublishController extends BaseApiController { private final SkillPublishService skillPublishService; private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; + private final SkillHubMetrics skillHubMetrics; public SkillPublishController(SkillPublishService skillPublishService, SkillPackageArchiveExtractor skillPackageArchiveExtractor, - ApiResponseFactory responseFactory) { + ApiResponseFactory responseFactory, + SkillHubMetrics skillHubMetrics) { super(responseFactory); this.skillPublishService = skillPublishService; this.skillPackageArchiveExtractor = skillPackageArchiveExtractor; + this.skillHubMetrics = skillHubMetrics; } @PostMapping("/{namespace}/publish") @@ -64,6 +68,7 @@ public class SkillPublishController extends BaseApiController { publishResult.version().getFileCount(), publishResult.version().getTotalSize() ); + skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name()); return ok("response.success.published", response); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java index 1882dac8..eab43d3f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.SkillTag; import com.iflytek.skillhub.domain.skill.service.SkillTagService; import com.iflytek.skillhub.dto.ApiResponse; @@ -12,6 +13,7 @@ import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @RestController @@ -29,9 +31,16 @@ public class SkillTagController extends BaseApiController { @GetMapping public ApiResponse> listTags( @PathVariable String namespace, - @PathVariable String slug) { + @PathVariable String slug, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - List tags = skillTagService.listTags(namespace, slug); + List tags = skillTagService.listTags( + namespace, + slug, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); List response = tags.stream() .map(TagResponse::from) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java new file mode 100644 index 00000000..f9791115 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -0,0 +1,127 @@ +package com.iflytek.skillhub.controller.support; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +@Component +public class ZipPackageExtractor { + + private static final int BUFFER_SIZE = 8192; + + private final SkillPublishProperties properties; + + public ZipPackageExtractor(SkillPublishProperties properties) { + this.properties = properties; + } + + public List extract(MultipartFile file) throws IOException { + List entries = new ArrayList<>(); + Set seenPaths = new HashSet<>(); + long totalSize = 0L; + + try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { + ZipEntry zipEntry; + while ((zipEntry = zis.getNextEntry()) != null) { + if (zipEntry.isDirectory()) { + zis.closeEntry(); + continue; + } + + if (entries.size() >= properties.getMaxFileCount()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Too many files: max " + properties.getMaxFileCount()); + } + + String normalizedPath = normalizeEntryPath(zipEntry.getName()); + if (!seenPaths.add(normalizedPath)) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Duplicate package path: " + normalizedPath); + } + + byte[] content = readEntry(zis, normalizedPath); + totalSize += content.length; + if (totalSize > properties.getMaxPackageSize()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Package too large: max " + properties.getMaxPackageSize() + " bytes"); + } + + entries.add(new PackageEntry( + normalizedPath, + content, + content.length, + determineContentType(normalizedPath) + )); + zis.closeEntry(); + } + } + + return entries; + } + + private byte[] readEntry(ZipInputStream zis, String path) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + long fileSize = 0L; + while ((read = zis.read(buffer)) != -1) { + fileSize += read; + if (fileSize > properties.getMaxSingleFileSize()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "File too large: " + path + " (max " + properties.getMaxSingleFileSize() + " bytes)"); + } + outputStream.write(buffer, 0, read); + } + return outputStream.toByteArray(); + } + + private String normalizeEntryPath(String path) { + if (path == null || path.isBlank()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", "Package entry path is blank"); + } + if (path.contains("\\")) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Package entry must use '/' separators: " + path); + } + + try { + Path normalized = Path.of(path).normalize(); + String normalizedPath = normalized.toString().replace('\\', '/'); + if (normalized.isAbsolute() + || normalizedPath.isBlank() + || normalizedPath.startsWith("../") + || normalizedPath.equals("..") + || path.startsWith("/") + || path.contains("//")) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Unsafe package path: " + path); + } + return normalizedPath; + } catch (InvalidPathException ex) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Invalid package path: " + path); + } + } + + private String determineContentType(String filename) { + if (filename.endsWith(".py")) return "text/x-python"; + if (filename.endsWith(".json")) return "application/json"; + if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; + if (filename.endsWith(".txt")) return "text/plain"; + if (filename.endsWith(".md")) return "text/markdown"; + return "application/octet-stream"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillActionRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillActionRequest.java new file mode 100644 index 00000000..61d40da8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillActionRequest.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.dto; + +public record AdminSkillActionRequest(String reason) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillMutationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillMutationResponse.java new file mode 100644 index 00000000..dff57c87 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillMutationResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record AdminSkillMutationResponse( + Long skillId, + Long versionId, + String action, + String status +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java new file mode 100644 index 00000000..2a80ad34 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ChangePasswordRequest( + @NotBlank(message = "当前密码不能为空") + String currentPassword, + @NotBlank(message = "新密码不能为空") + String newPassword +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java new file mode 100644 index 00000000..62ba3b8d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LocalLoginRequest( + @NotBlank(message = "用户名不能为空") + String username, + @NotBlank(message = "密码不能为空") + String password +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java new file mode 100644 index 00000000..1f560ea8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LocalRegisterRequest( + @NotBlank(message = "用户名不能为空") + String username, + @NotBlank(message = "密码不能为空") + String password, + @Email(message = "邮箱格式不正确") + String email +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java new file mode 100644 index 00000000..588d7eb9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record MergeInitiateRequest( + @NotBlank(message = "待合并账号标识不能为空") + String secondaryIdentifier +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateResponse.java new file mode 100644 index 00000000..86e7fab8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record MergeInitiateResponse( + Long mergeRequestId, + String secondaryUserId, + String verificationToken, + String expiresAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java new file mode 100644 index 00000000..3d0e63d4 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record MergeVerifyRequest( + @NotNull(message = "合并请求 ID 不能为空") + Long mergeRequestId, + @NotBlank(message = "验证 token 不能为空") + String verificationToken +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index 6bdafbf6..12d0019a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.dto; +import java.math.BigDecimal; + public record SkillDetailResponse( Long id, String slug, @@ -9,6 +11,9 @@ public record SkillDetailResponse( String status, Long downloadCount, Integer starCount, + BigDecimal ratingAvg, + Integer ratingCount, + boolean hidden, String latestVersion, String namespace ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index 49625f63..822039e8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.exception; +import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; @@ -32,6 +33,13 @@ public class GlobalExceptionHandler { apiResponseFactory.error(status.value(), ex.messageCode(), ex.messageArgs())); } + @ExceptionHandler(AuthFlowException.class) + public ResponseEntity> handleAuthFlowException(AuthFlowException ex) { + HttpStatus status = ex.getStatus(); + return ResponseEntity.status(status).body( + apiResponseFactory.error(status.value(), ex.getMessageCode(), ex.getMessageArgs())); + } + @ExceptionHandler(DomainBadRequestException.class) public ResponseEntity> handleDomainBadRequest(DomainBadRequestException ex) { return ResponseEntity.badRequest().body( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java new file mode 100644 index 00000000..75e24c35 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java @@ -0,0 +1,34 @@ +package com.iflytek.skillhub.metrics; + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.stereotype.Component; + +@Component +public class SkillHubMetrics { + + private final MeterRegistry meterRegistry; + + public SkillHubMetrics(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + public void incrementUserRegister() { + meterRegistry.counter("skillhub.user.register").increment(); + } + + public void recordLocalLogin(boolean success) { + meterRegistry.counter( + "skillhub.auth.login", + "method", "local", + "result", success ? "success" : "failure" + ).increment(); + } + + public void incrementSkillPublish(String namespace, String status) { + meterRegistry.counter( + "skillhub.skill.publish", + "namespace", namespace, + "status", status + ).increment(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java new file mode 100644 index 00000000..09c12260 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java @@ -0,0 +1,140 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.dto.AdminUserSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AdminUserManagementService { + + private final UserAccountRepository userAccountRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final RoleRepository roleRepository; + + public AdminUserManagementService(UserAccountRepository userAccountRepository, + UserRoleBindingRepository userRoleBindingRepository, + RoleRepository roleRepository) { + this.userAccountRepository = userAccountRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.roleRepository = roleRepository; + } + + @Transactional(readOnly = true) + public PageResponse listUsers(String keyword, String status, int page, int size) { + UserStatus userStatus = parseStatus(status); + Page users = userAccountRepository.search(normalize(keyword), userStatus, PageRequest.of(page, size)); + List items = users.getContent().stream() + .map(this::toSummary) + .toList(); + return PageResponse.from(new PageImpl<>(items, users.getPageable(), users.getTotalElements())); + } + + @Transactional + public AdminUserSummaryResponse updateUserRole(String userId, String roleCode, PlatformPrincipal principal) { + UserAccount user = loadUser(userId); + if (principal != null + && !principal.platformRoles().contains("SUPER_ADMIN") + && "SUPER_ADMIN".equalsIgnoreCase(roleCode)) { + throw new DomainForbiddenException("error.admin.role.assign_super_admin_forbidden"); + } + Role role = roleRepository.findByCode(roleCode) + .orElseThrow(() -> new DomainBadRequestException("error.role.notFound", roleCode)); + + List existing = userRoleBindingRepository.findByUserId(userId); + boolean alreadyAssigned = existing.stream().anyMatch(binding -> binding.getRole().getCode().equals(roleCode)); + if (!alreadyAssigned) { + userRoleBindingRepository.save(new UserRoleBinding(userId, role)); + } + return toSummary(user); + } + + @Transactional + public AdminUserSummaryResponse approveUser(String userId) { + UserAccount user = loadUser(userId); + user.setStatus(UserStatus.ACTIVE); + return toSummary(userAccountRepository.save(user)); + } + + @Transactional + public AdminUserSummaryResponse updateUserStatus(String userId, String status) { + UserAccount user = loadUser(userId); + user.setStatus(parseRequiredStatus(status)); + return toSummary(userAccountRepository.save(user)); + } + + @Transactional + public AdminUserSummaryResponse disableUser(String userId) { + UserAccount user = loadUser(userId); + user.setStatus(UserStatus.DISABLED); + return toSummary(userAccountRepository.save(user)); + } + + @Transactional + public AdminUserSummaryResponse enableUser(String userId) { + UserAccount user = loadUser(userId); + user.setStatus(UserStatus.ACTIVE); + return toSummary(userAccountRepository.save(user)); + } + + private UserAccount loadUser(String userId) { + return userAccountRepository.findById(userId) + .orElseThrow(() -> new DomainNotFoundException("error.user.notFound", userId)); + } + + private AdminUserSummaryResponse toSummary(UserAccount user) { + Set roles = new LinkedHashSet<>(); + userRoleBindingRepository.findByUserId(user.getId()).stream() + .map(binding -> binding.getRole().getCode()) + .sorted(Comparator.naturalOrder()) + .forEach(roles::add); + return new AdminUserSummaryResponse( + user.getId(), + user.getDisplayName(), + user.getEmail(), + user.getStatus().name(), + List.copyOf(roles), + user.getCreatedAt() + ); + } + + private String normalize(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return keyword.trim(); + } + + private UserStatus parseStatus(String status) { + if (status == null || status.isBlank()) { + return null; + } + return parseRequiredStatus(status); + } + + private UserStatus parseRequiredStatus(String status) { + try { + return UserStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("error.user.status.invalid", status); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index b262621f..6f2684f0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -5,8 +5,10 @@ import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.social.SkillStarRepository; import com.iflytek.skillhub.dto.SkillSummaryResponse; import org.springframework.stereotype.Service; +import org.springframework.data.domain.PageRequest; import java.util.Comparator; import java.util.List; @@ -21,14 +23,17 @@ public class MySkillAppService { private final SkillRepository skillRepository; private final NamespaceRepository namespaceRepository; private final SkillVersionRepository skillVersionRepository; + private final SkillStarRepository skillStarRepository; public MySkillAppService( SkillRepository skillRepository, NamespaceRepository namespaceRepository, - SkillVersionRepository skillVersionRepository) { + SkillVersionRepository skillVersionRepository, + SkillStarRepository skillStarRepository) { this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.skillVersionRepository = skillVersionRepository; + this.skillStarRepository = skillStarRepository; } public List listMySkills(String userId) { @@ -62,6 +67,50 @@ public class MySkillAppService { .toList(); } + public List listMyStars(String userId) { + List stars = skillStarRepository.findByUserId( + userId, + PageRequest.of(0, 200) + ).getContent(); + + List skillIds = stars.stream() + .map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId) + .distinct() + .toList(); + Map skillsById = skillIds.isEmpty() + ? Map.of() + : skillRepository.findByIdIn(skillIds).stream() + .collect(Collectors.toMap(Skill::getId, Function.identity())); + + List latestVersionIds = skillsById.values().stream() + .map(Skill::getLatestVersionId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + Map versionsById = latestVersionIds.isEmpty() + ? Map.of() + : skillVersionRepository.findByIdIn(latestVersionIds).stream() + .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); + + List namespaceIds = skillsById.values().stream() + .map(Skill::getNamespaceId) + .distinct() + .toList(); + Map namespaceSlugsById = namespaceIds.isEmpty() + ? Map.of() + : namespaceRepository.findByIdIn(namespaceIds).stream() + .collect(Collectors.toMap( + com.iflytek.skillhub.domain.namespace.Namespace::getId, + com.iflytek.skillhub.domain.namespace.Namespace::getSlug)); + + return stars.stream() + .sorted(Comparator.comparing(com.iflytek.skillhub.domain.social.SkillStar::getCreatedAt).reversed()) + .map(star -> skillsById.get(star.getSkillId())) + .filter(java.util.Objects::nonNull) + .map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById)) + .toList(); + } + private SkillSummaryResponse toSummaryResponse( Skill skill, Map versionsById, diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index a9e25284..450d090b 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -1,6 +1,13 @@ server: port: 8080 shutdown: graceful + servlet: + session: + cookie: + http-only: true + secure: ${SESSION_COOKIE_SECURE:false} + same-site: lax + max-age: 28800 spring: messages: @@ -53,13 +60,15 @@ skillhub: access-policy: mode: OPEN storage: - type: local + provider: local local: base-path: ${STORAGE_BASE_PATH:/tmp/skillhub-storage} search: engine: postgres rebuild-on-startup: false publish: + max-file-count: 100 + max-single-file-size: 1048576 # 1MB max-package-size: 104857600 # 100MB allowed-file-extensions: .py,.json,.yaml,.yml,.txt,.md,.sh @@ -67,10 +76,16 @@ management: endpoints: web: exposure: - include: health,info + include: health,info,prometheus,metrics endpoint: health: show-details: when-authorized + metrics: + tags: + application: skillhub + export: + prometheus: + enabled: true --- # Docker profile diff --git a/server/skillhub-app/src/main/resources/db/migration/V5__phase4_auth_governance.sql b/server/skillhub-app/src/main/resources/db/migration/V5__phase4_auth_governance.sql new file mode 100644 index 00000000..4e746456 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V5__phase4_auth_governance.sql @@ -0,0 +1,32 @@ +CREATE TABLE local_credential ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), + username VARCHAR(64) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + failed_attempts INT NOT NULL DEFAULT 0, + locked_until TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX idx_local_credential_username ON local_credential (username); +CREATE UNIQUE INDEX idx_local_credential_user_id ON local_credential (user_id); + +CREATE TABLE account_merge_request ( + id BIGSERIAL PRIMARY KEY, + primary_user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), + secondary_user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + verification_token VARCHAR(255), + token_expires_at TIMESTAMP, + completed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_merge_primary_status ON account_merge_request (primary_user_id, status); +CREATE UNIQUE INDEX idx_merge_secondary_pending + ON account_merge_request (secondary_user_id) + WHERE status = 'PENDING'; +CREATE INDEX idx_merge_token_pending + ON account_merge_request (verification_token) + WHERE status = 'PENDING'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V6__phase4_governance_audit.sql b/server/skillhub-app/src/main/resources/db/migration/V6__phase4_governance_audit.sql new file mode 100644 index 00000000..6f0bde78 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V6__phase4_governance_audit.sql @@ -0,0 +1,11 @@ +ALTER TABLE skill ADD COLUMN hidden BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE skill ADD COLUMN hidden_at TIMESTAMP; +ALTER TABLE skill ADD COLUMN hidden_by VARCHAR(128) REFERENCES user_account(id); + +ALTER TABLE skill_version ADD COLUMN yanked_at TIMESTAMP; +ALTER TABLE skill_version ADD COLUMN yanked_by VARCHAR(128) REFERENCES user_account(id); +ALTER TABLE skill_version ADD COLUMN yank_reason TEXT; + +CREATE INDEX idx_skill_hidden ON skill(hidden) WHERE hidden = TRUE; +CREATE INDEX idx_audit_log_actor_time ON audit_log(actor_user_id, created_at DESC); +CREATE INDEX idx_audit_log_action_time ON audit_log(action, created_at DESC); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializerTest.java new file mode 100644 index 00000000..945530c6 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializerTest.java @@ -0,0 +1,98 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.DefaultApplicationArguments; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LocalDevDataInitializerTest { + + @Mock private UserAccountRepository userAccountRepository; + @Mock private NamespaceRepository namespaceRepository; + @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock private RoleRepository roleRepository; + @Mock private UserRoleBindingRepository userRoleBindingRepository; + + private LocalDevDataInitializer initializer; + + @BeforeEach + void setUp() { + initializer = new LocalDevDataInitializer( + userAccountRepository, + namespaceRepository, + namespaceMemberRepository, + roleRepository, + userRoleBindingRepository + ); + } + + @Test + void shouldSeedLocalUsersGlobalMembershipAndSuperAdminRole() throws Exception { + Namespace global = new Namespace("global", "Global", "system"); + setField(global, "id", 1L); + + Role superAdminRole = new Role(); + setField(superAdminRole, "id", 1L); + setField(superAdminRole, "code", "SUPER_ADMIN"); + + when(userAccountRepository.findById(LocalDevDataInitializer.LOCAL_USER_ID)).thenReturn(Optional.empty()); + when(userAccountRepository.findById(LocalDevDataInitializer.LOCAL_ADMIN_ID)).thenReturn(Optional.empty()); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(anyLong(), any())).thenReturn(Optional.empty()); + when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleRepository.findByCode("SUPER_ADMIN")).thenReturn(Optional.of(superAdminRole)); + when(userRoleBindingRepository.findByUserId(LocalDevDataInitializer.LOCAL_ADMIN_ID)).thenReturn(List.of()); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userAccountRepository, times(2)).save(userCaptor.capture()); + List savedUsers = userCaptor.getAllValues(); + assertTrue(savedUsers.stream().anyMatch(user -> LocalDevDataInitializer.LOCAL_USER_ID.equals(user.getId()))); + assertTrue(savedUsers.stream().anyMatch(user -> LocalDevDataInitializer.LOCAL_ADMIN_ID.equals(user.getId()))); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository, times(2)).save(memberCaptor.capture()); + assertEquals( + List.of(LocalDevDataInitializer.LOCAL_USER_ID, LocalDevDataInitializer.LOCAL_ADMIN_ID), + memberCaptor.getAllValues().stream().map(NamespaceMember::getUserId).toList() + ); + assertTrue(memberCaptor.getAllValues().stream().allMatch(member -> member.getRole() == NamespaceRole.OWNER)); + + ArgumentCaptor roleBindingCaptor = ArgumentCaptor.forClass(UserRoleBinding.class); + verify(userRoleBindingRepository).save(roleBindingCaptor.capture()); + assertEquals(LocalDevDataInitializer.LOCAL_ADMIN_ID, roleBindingCaptor.getValue().getUserId()); + assertEquals("SUPER_ADMIN", roleBindingCaptor.getValue().getRole().getCode()); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index e9c72230..df671106 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.service.SkillSearchAppService; import org.junit.jupiter.api.Test; @@ -43,6 +44,9 @@ class ClawHubCompatControllerTest { @MockBean private SkillSearchAppService skillSearchAppService; + @MockBean + private SkillQueryService skillQueryService; + @Test void search_returns_mapped_results() throws Exception { when(skillSearchAppService.search("test", null, "relevance", 0, 20, null, null)) @@ -76,6 +80,9 @@ class ClawHubCompatControllerTest { @Test void resolve_returns_correct_downloadUrl() throws Exception { + when(skillQueryService.resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of())) + .thenReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, "global", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/global/my-skill/download")); mockMvc.perform(get("/api/compat/v1/resolve/my-skill")) .andExpect(status().isOk()) .andExpect(jsonPath("$.canonicalSlug").value("my-skill")) @@ -85,6 +92,9 @@ class ClawHubCompatControllerTest { @Test void resolve_with_namespace_returns_correct_downloadUrl() throws Exception { + when(skillQueryService.resolveVersion("team-ai", "my-skill", null, "latest", null, null, java.util.Map.of())) + .thenReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, "team-ai", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/team-ai/my-skill/download")); mockMvc.perform(get("/api/compat/v1/resolve/team-ai--my-skill")) .andExpect(status().isOk()) .andExpect(jsonPath("$.canonicalSlug").value("team-ai--my-skill")) @@ -94,6 +104,9 @@ class ClawHubCompatControllerTest { @Test void resolve_with_version_returns_specified_version() throws Exception { + when(skillQueryService.resolveVersion("global", "my-skill", "1.0.0", null, null, null, java.util.Map.of())) + .thenReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, "global", "my-skill", "1.0.0", 2L, "sha", true, "/api/v1/skills/global/my-skill/download")); mockMvc.perform(get("/api/compat/v1/resolve/my-skill") .param("version", "1.0.0")) .andExpect(status().isOk()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java new file mode 100644 index 00000000..7d0afcd6 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java @@ -0,0 +1,100 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.merge.AccountMergeService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AccountMergeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AccountMergeService accountMergeService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @Test + void initiate_returnsVerificationToken() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of()); + given(accountMergeService.initiate("usr_primary", "secondary")) + .willReturn(new AccountMergeService.InitiationResult(1L, "usr_secondary", "merge-token", LocalDateTime.parse("2026-03-12T22:30:00"))); + + mockMvc.perform(post("/api/v1/account/merge/initiate") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"secondaryIdentifier":"secondary"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.mergeRequestId").value(1)) + .andExpect(jsonPath("$.data.secondaryUserId").value("usr_secondary")) + .andExpect(jsonPath("$.data.verificationToken").value("merge-token")); + } + + @Test + void verify_returnsSuccessMessage() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); + + mockMvc.perform(post("/api/v1/account/merge/verify") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"mergeRequestId":1,"verificationToken":"merge-token"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.message").value("Account merge verified")); + + verify(accountMergeService).verify("usr_primary", 1L, "merge-token"); + } + + @Test + void confirm_returnsSuccessMessage() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); + + mockMvc.perform(post("/api/v1/account/merge/confirm") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"mergeRequestId":1} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.message").value("Account merge completed")); + + verify(accountMergeService).confirm("usr_primary", 1L); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index c13c75f3..53d710bb 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -18,6 +18,7 @@ import java.util.Set; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -59,6 +60,9 @@ class AuthControllerTest { mockMvc.perform(get("/api/v1/auth/me").with(authentication(auth))) .andExpect(status().isOk()) + .andExpect(header().string("X-Content-Type-Options", "nosniff")) + .andExpect(header().string("X-Frame-Options", "DENY")) + .andExpect(header().string("Referrer-Policy", "strict-origin-when-cross-origin")) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.msg").isNotEmpty()) .andExpect(jsonPath("$.data.userId").value("user-42")) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java new file mode 100644 index 00000000..b0d84d8e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -0,0 +1,151 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class LocalAuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private LocalAuthService localAuthService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private SkillHubMetrics skillHubMetrics; + + @Test + void login_returnsCurrentUserEnvelope() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", + "alice", + "alice@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + given(localAuthService.login("alice", "Abcd123!")).willReturn(principal); + + mockMvc.perform(post("/api/v1/auth/local/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"alice","password":"Abcd123!"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("usr_1")) + .andExpect(jsonPath("$.data.oauthProvider").value("local")); + verify(skillHubMetrics).recordLocalLogin(true); + verify(skillHubMetrics, never()).recordLocalLogin(false); + } + + @Test + void register_returnsCreatedEnvelope() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_2", + "bob", + "bob@example.com", + "", + "local", + Set.of() + ); + given(localAuthService.register("bob", "Abcd123!", "bob@example.com")).willReturn(principal); + + mockMvc.perform(post("/api/v1/auth/local/register") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"bob","password":"Abcd123!","email":"bob@example.com"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.displayName").value("bob")); + verify(skillHubMetrics).incrementUserRegister(); + } + + @Test + void login_failure_recordsFailureMetric() throws Exception { + given(localAuthService.login("alice", "wrong")) + .willThrow(new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials")); + + mockMvc.perform(post("/api/v1/auth/local/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"alice","password":"wrong"} + """)) + .andExpect(status().isUnauthorized()); + verify(skillHubMetrics).recordLocalLogin(false); + verify(skillHubMetrics, never()).recordLocalLogin(true); + } + + @Test + void changePassword_requiresAuthentication() throws Exception { + mockMvc.perform(post("/api/v1/auth/local/change-password") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"currentPassword":"old","newPassword":"Newpass123!"} + """)) + .andExpect(status().isUnauthorized()); + } + + @Test + void changePassword_withAuthentication_returnsUpdated() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_3", + "carol", + "carol@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + mockMvc.perform(post("/api/v1/auth/local/change-password") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"currentPassword":"old","newPassword":"Newpass123!"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java index 2e57b0f7..b4cad51a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -84,11 +85,15 @@ class PromotionPortalControllerTest { @MockBean private ReviewPermissionChecker permissionChecker; + @MockBean + private AuditLogService auditLogService; + @Test void submitPromotion_passesNamespaceRolesToService() throws Exception { PromotionRequest request = createPromotionRequest(1L, "user-1"); stubNamespaceRoles("user-1", List.of(new NamespaceMember(5L, "user-1", NamespaceRole.ADMIN))); - given(promotionService.submitPromotion(10L, 20L, 30L, "user-1", Map.of(5L, NamespaceRole.ADMIN))) + given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of()); + given(promotionService.submitPromotion(10L, 20L, 30L, "user-1", Map.of(5L, NamespaceRole.ADMIN), Set.of())) .willReturn(request); stubPromotionResponse(request); @@ -121,7 +126,7 @@ class PromotionPortalControllerTest { stubNamespaceRoles("user-1", List.of()); given(promotionRequestRepository.findById(1L)).willReturn(Optional.of(request)); given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of()); - given(permissionChecker.canReadPromotion(request, "user-1", Set.of())).willReturn(true); + given(promotionService.canViewPromotion(request, "user-1", Set.of())).willReturn(true); stubPromotionResponse(request); mockMvc.perform(get("/api/v1/promotions/1").with(auth("user-1"))) @@ -136,7 +141,7 @@ class PromotionPortalControllerTest { stubNamespaceRoles("user-9", List.of()); given(promotionRequestRepository.findById(1L)).willReturn(Optional.of(request)); given(rbacService.getUserRoleCodes("user-9")).willReturn(Set.of()); - given(permissionChecker.canReadPromotion(request, "user-9", Set.of())).willReturn(false); + given(promotionService.canViewPromotion(request, "user-9", Set.of())).willReturn(false); mockMvc.perform(get("/api/v1/promotions/1").with(auth("user-9"))) .andExpect(status().isForbidden()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java index 44551a8e..77581ab4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -84,11 +85,15 @@ class ReviewPortalControllerTest { @MockBean private ReviewPermissionChecker permissionChecker; + @MockBean + private AuditLogService auditLogService; + @Test void submitReview_passesNamespaceRolesToService() throws Exception { ReviewTask task = createReviewTask(1L, 20L, "user-1"); stubNamespaceRoles("user-1", List.of(new NamespaceMember(20L, "user-1", NamespaceRole.MEMBER))); - given(reviewService.submitReview(100L, "user-1", Map.of(20L, NamespaceRole.MEMBER))).willReturn(task); + given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of()); + given(reviewService.submitReview(100L, "user-1", Map.of(20L, NamespaceRole.MEMBER), Set.of())).willReturn(task); stubReviewResponse(task); mockMvc.perform(post("/api/v1/reviews") @@ -130,7 +135,7 @@ class ReviewPortalControllerTest { given(reviewTaskRepository.findById(1L)).willReturn(Optional.of(task)); given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace)); given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of()); - given(permissionChecker.canReadReview(task, "user-1", namespace.getType(), Map.of(), Set.of())).willReturn(true); + given(reviewService.canViewReview(task, "user-1", namespace.getType(), Map.of(), Set.of())).willReturn(true); stubReviewResponse(task); mockMvc.perform(get("/api/v1/reviews/1").with(auth("user-1"))) @@ -147,7 +152,7 @@ class ReviewPortalControllerTest { given(reviewTaskRepository.findById(1L)).willReturn(Optional.of(task)); given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace)); given(rbacService.getUserRoleCodes("user-9")).willReturn(Set.of()); - given(permissionChecker.canReadReview(task, "user-9", namespace.getType(), Map.of(), Set.of())).willReturn(false); + given(reviewService.canViewReview(task, "user-9", namespace.getType(), Map.of(), Set.of())).willReturn(false); mockMvc.perform(get("/api/v1/reviews/1").with(auth("user-9"))) .andExpect(status().isForbidden()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillTagControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillTagControllerTest.java index 2daffdcd..8c664b4c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillTagControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillTagControllerTest.java @@ -12,8 +12,10 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.util.List; +import java.util.Map; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -35,7 +37,7 @@ class SkillTagControllerTest { @Test void list_tags_is_public() throws Exception { - when(skillTagService.listTags(eq("team"), eq("demo"))) + when(skillTagService.listTags(eq("team"), eq("demo"), isNull(), eq(Map.of()))) .thenReturn(List.of(new SkillTag(1L, "latest", 2L, "user-1"))); mockMvc.perform(get("/api/v1/skills/team/demo/tags")) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java new file mode 100644 index 00000000..16c7c43b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java @@ -0,0 +1,89 @@ +package com.iflytek.skillhub.controller.admin; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminSkillControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SkillGovernanceService skillGovernanceService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void hideSkill_returnsUpdatedResponse() throws Exception { + Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC); + given(skillGovernanceService.hideSkill(org.mockito.ArgumentMatchers.eq(10L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("policy"))) + .willReturn(skill); + + PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SKILL_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))); + + mockMvc.perform(post("/api/v1/admin/skills/10/hide") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"policy\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(10)) + .andExpect(jsonPath("$.data.action").value("HIDE")); + } + + @Test + void yankVersion_returnsUpdatedResponse() throws Exception { + SkillVersion version = new SkillVersion(10L, "1.0.0", "owner"); + version.setStatus(SkillVersionStatus.YANKED); + given(skillGovernanceService.yankVersion(org.mockito.ArgumentMatchers.eq(33L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("broken"))) + .willReturn(version); + + PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SKILL_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))); + + mockMvc.perform(post("/api/v1/admin/skills/versions/33/yank") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"broken\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.versionId").value(33)) + .andExpect(jsonPath("$.data.action").value("YANK")) + .andExpect(jsonPath("$.data.status").value("YANKED")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java new file mode 100644 index 00000000..ef24b46b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java @@ -0,0 +1,75 @@ +package com.iflytek.skillhub.controller.portal; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import java.io.ByteArrayInputStream; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillControllerDownloadTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SkillQueryService skillQueryService; + + @MockBean + private SkillDownloadService skillDownloadService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void downloadVersion_redirectsToPresignedUrlWhenAvailable() throws Exception { + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, java.util.Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + null, + "demo-skill-1.0.0.zip", + 128L, + "application/zip", + "https://download.example/presigned" + )); + + mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download")) + .andExpect(status().isFound()) + .andExpect(header().string("Location", "https://download.example/presigned")); + } + + @Test + void downloadVersion_streamsWhenPresignedUrlUnavailable() throws Exception { + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, java.util.Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + new ByteArrayInputStream("zip".getBytes()), + "demo-skill-1.0.0.zip", + 3L, + "application/zip", + null + )); + + mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", "attachment; filename=\"demo-skill-1.0.0.zip\"")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java new file mode 100644 index 00000000..606ae1ba --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java @@ -0,0 +1,123 @@ +package com.iflytek.skillhub.controller.portal; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillPublishControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SkillPublishService skillPublishService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private SkillHubMetrics skillHubMetrics; + + @Test + void publish_recordsMetricsAfterSuccess() throws Exception { + SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + version.setFileCount(1); + version.setTotalSize(128L); + ReflectionTestUtils.setField(version, "id", 34L); + + given(skillPublishService.publishFromEntries(eq("global"), anyList(), eq("usr_1"), eq(SkillVisibility.PUBLIC))) + .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); + + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", + "publisher", + "publisher@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "skill.zip", + "application/zip", + buildZipBytes() + ); + + mockMvc.perform(multipart("/api/v1/skills/global/publish") + .file(file) + .param("visibility", "PUBLIC") + .requestAttr("userId", "usr_1") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(12)) + .andExpect(jsonPath("$.data.slug").value("demo-skill")); + + verify(skillHubMetrics).incrementSkillPublish("global", "PENDING_REVIEW"); + } + + private byte[] buildZipBytes() throws Exception { + try (ByteArrayOutputStream output = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) { + zip.putNextEntry(new ZipEntry("SKILL.md")); + zip.write(""" + --- + name: Demo Skill + version: 1.0.0 + --- + """.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.finish(); + return output.toByteArray(); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java new file mode 100644 index 00000000..13c0be27 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java @@ -0,0 +1,57 @@ +package com.iflytek.skillhub.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.core.env.Environment; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class PrometheusEndpointTest { + + @Autowired + private SkillHubMetrics skillHubMetrics; + + @Autowired + private MeterRegistry meterRegistry; + + @Autowired + private Environment environment; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void prometheusEndpoint_exposesCustomMetrics() { + skillHubMetrics.incrementUserRegister(); + skillHubMetrics.recordLocalLogin(true); + skillHubMetrics.incrementSkillPublish("global", "PENDING_REVIEW"); + + assertThat(environment.getProperty("management.endpoints.web.exposure.include")) + .contains("prometheus"); + assertThat(meterRegistry.get("skillhub.user.register").counter().count()).isEqualTo(1.0d); + assertThat(meterRegistry.get("skillhub.auth.login") + .tag("method", "local") + .tag("result", "success") + .counter() + .count()).isEqualTo(1.0d); + assertThat(meterRegistry.get("skillhub.skill.publish") + .tag("namespace", "global") + .tag("status", "PENDING_REVIEW") + .counter() + .count()).isEqualTo(1.0d); + } +} diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml index e0b7f665..40e3400f 100644 --- a/server/skillhub-auth/pom.xml +++ b/server/skillhub-auth/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.2 skillhub-auth diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 828a6755..3741cc49 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -11,6 +11,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; import org.springframework.http.HttpMethod; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -21,6 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @Configuration @@ -70,14 +73,17 @@ public class SecurityConfig { "/api/v1/health", "/api/v1/auth/providers", "/api/v1/auth/me", + "/api/v1/auth/local/**", "/api/v1/cli/auth/device/**", "/api/v1/cli/check", "/actuator/health", + "/actuator/prometheus", "/v3/api-docs/**", "/swagger-ui/**", "/.well-known/**", "/api/compat/v1/search", - "/api/compat/v1/resolve/**" + "/api/compat/v1/resolve/**", + "/api/compat/v1/download/**" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/skills/*/star", "/api/v1/skills/*/rating").authenticated() .requestMatchers( @@ -105,6 +111,14 @@ public class SecurityConfig { .successHandler(successHandler) .failureHandler(failureHandler) ) + .headers(headers -> headers + .contentTypeOptions(contentTypeOptions -> {}) + .frameOptions(frameOptions -> frameOptions.deny()) + .httpStrictTransportSecurity(hsts -> hsts + .includeSubDomains(true) + .maxAgeInSeconds(31536000)) + .referrerPolicy(referrer -> referrer.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)) + ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) ) @@ -131,4 +145,9 @@ public class SecurityConfig { return http.build(); } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(12); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java index 9b5629dd..8fb654d2 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java @@ -62,7 +62,11 @@ public class ApiToken { void prePersist() { this.createdAt = LocalDateTime.now(); } public Long getId() { return id; } + public String getSubjectType() { return subjectType; } + public String getSubjectId() { return subjectId; } + public void setSubjectId(String subjectId) { this.subjectId = subjectId; } public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } public String getName() { return name; } public String getTokenPrefix() { return tokenPrefix; } public String getTokenHash() { return tokenHash; } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java index 3f7807ff..843cbdd0 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java @@ -33,5 +33,6 @@ public class UserRoleBinding { public Long getId() { return id; } public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } public Role getRole() { return role; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java new file mode 100644 index 00000000..b2127c80 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.auth.exception; + +import org.springframework.http.HttpStatus; + +public class AuthFlowException extends RuntimeException { + + private final HttpStatus status; + private final String messageCode; + private final Object[] messageArgs; + + public AuthFlowException(HttpStatus status, String messageCode, Object... messageArgs) { + super(messageCode); + this.status = status; + this.messageCode = messageCode; + this.messageArgs = messageArgs; + } + + public HttpStatus getStatus() { + return status; + } + + public String getMessageCode() { + return messageCode; + } + + public Object[] getMessageArgs() { + return messageArgs; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java index 4227b326..871f79cb 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java @@ -5,6 +5,7 @@ import com.iflytek.skillhub.auth.oauth.OAuthClaims; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; @@ -20,13 +21,16 @@ public class IdentityBindingService { private final IdentityBindingRepository bindingRepo; private final UserAccountRepository userRepo; private final UserRoleBindingRepository roleBindingRepo; + private final GlobalNamespaceMembershipService globalNamespaceMembershipService; public IdentityBindingService(IdentityBindingRepository bindingRepo, - UserAccountRepository userRepo, - UserRoleBindingRepository roleBindingRepo) { + UserAccountRepository userRepo, + UserRoleBindingRepository roleBindingRepo, + GlobalNamespaceMembershipService globalNamespaceMembershipService) { this.bindingRepo = bindingRepo; this.userRepo = userRepo; this.roleBindingRepo = roleBindingRepo; + this.globalNamespaceMembershipService = globalNamespaceMembershipService; } @Transactional @@ -54,6 +58,9 @@ public class IdentityBindingService { ); user.setStatus(initialStatus); user = userRepo.save(user); + if (initialStatus == UserStatus.ACTIVE) { + globalNamespaceMembershipService.ensureMember(user.getId()); + } binding = new IdentityBinding(user.getId(), claims.provider(), claims.subject(), claims.providerLogin()); bindingRepo.save(binding); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java new file mode 100644 index 00000000..1addd5f7 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -0,0 +1,193 @@ +package com.iflytek.skillhub.auth.local; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class LocalAuthService { + + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$"); + private static final int MAX_FAILED_ATTEMPTS = 5; + private static final Duration LOCK_DURATION = Duration.ofMinutes(15); + + private final LocalCredentialRepository credentialRepository; + private final UserAccountRepository userAccountRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final GlobalNamespaceMembershipService globalNamespaceMembershipService; + private final PasswordPolicyValidator passwordPolicyValidator; + private final PasswordEncoder passwordEncoder; + + public LocalAuthService(LocalCredentialRepository credentialRepository, + UserAccountRepository userAccountRepository, + UserRoleBindingRepository userRoleBindingRepository, + GlobalNamespaceMembershipService globalNamespaceMembershipService, + PasswordPolicyValidator passwordPolicyValidator, + PasswordEncoder passwordEncoder) { + this.credentialRepository = credentialRepository; + this.userAccountRepository = userAccountRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.globalNamespaceMembershipService = globalNamespaceMembershipService; + this.passwordPolicyValidator = passwordPolicyValidator; + this.passwordEncoder = passwordEncoder; + } + + @Transactional + public PlatformPrincipal register(String username, String password, String email) { + String normalizedUsername = normalizeUsername(username); + validateUsername(normalizedUsername); + + if (credentialRepository.existsByUsernameIgnoreCase(normalizedUsername)) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.local.username.exists"); + } + + String normalizedEmail = normalizeEmail(email); + if (normalizedEmail != null && userAccountRepository.findByEmailIgnoreCase(normalizedEmail).isPresent()) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.local.email.exists"); + } + + var passwordErrors = passwordPolicyValidator.validate(password); + if (!passwordErrors.isEmpty()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, passwordErrors.getFirst()); + } + + UserAccount user = new UserAccount( + "usr_" + UUID.randomUUID(), + normalizedUsername, + normalizedEmail, + null + ); + user.setStatus(UserStatus.ACTIVE); + userAccountRepository.save(user); + + credentialRepository.save(new LocalCredential( + user.getId(), + normalizedUsername, + passwordEncoder.encode(password) + )); + globalNamespaceMembershipService.ensureMember(user.getId()); + + return buildPrincipal(user); + } + + @Transactional + public PlatformPrincipal login(String username, String password) { + String normalizedUsername = normalizeUsername(username); + LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername) + .orElseThrow(() -> invalidCredentials()); + + UserAccount user = userAccountRepository.findById(credential.getUserId()) + .orElseThrow(() -> new IllegalStateException("User not found for local credential")); + + ensureUserCanLogin(user); + ensureNotLocked(credential); + + if (!passwordEncoder.matches(password, credential.getPasswordHash())) { + handleFailedLogin(credential); + throw invalidCredentials(); + } + + credential.setFailedAttempts(0); + credential.setLockedUntil(null); + credentialRepository.save(credential); + return buildPrincipal(user); + } + + @Transactional + public void changePassword(String userId, String currentPassword, String newPassword) { + LocalCredential credential = credentialRepository.findByUserId(userId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.notEnabled")); + + if (!passwordEncoder.matches(currentPassword, credential.getPasswordHash())) { + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials"); + } + + var passwordErrors = passwordPolicyValidator.validate(newPassword); + if (!passwordErrors.isEmpty()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, passwordErrors.getFirst()); + } + + credential.setPasswordHash(passwordEncoder.encode(newPassword)); + credential.setFailedAttempts(0); + credential.setLockedUntil(null); + credentialRepository.save(credential); + } + + private PlatformPrincipal buildPrincipal(UserAccount user) { + Set roles = userRoleBindingRepository.findByUserId(user.getId()).stream() + .map(binding -> binding.getRole().getCode()) + .collect(Collectors.toSet()); + return new PlatformPrincipal( + user.getId(), + user.getDisplayName(), + user.getEmail(), + user.getAvatarUrl(), + "local", + roles + ); + } + + private void ensureUserCanLogin(UserAccount user) { + if (user.getStatus() == UserStatus.DISABLED) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountDisabled"); + } + if (user.getStatus() == UserStatus.PENDING) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountPending"); + } + if (user.getStatus() == UserStatus.MERGED) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountMerged"); + } + } + + private void ensureNotLocked(LocalCredential credential) { + if (credential.getLockedUntil() != null && credential.getLockedUntil().isAfter(LocalDateTime.now())) { + long minutes = Math.max(1, Duration.between(LocalDateTime.now(), credential.getLockedUntil()).toMinutes()); + throw new AuthFlowException(HttpStatus.LOCKED, "error.auth.local.locked", minutes); + } + } + + private void handleFailedLogin(LocalCredential credential) { + int failedAttempts = credential.getFailedAttempts() + 1; + credential.setFailedAttempts(failedAttempts); + if (failedAttempts >= MAX_FAILED_ATTEMPTS) { + credential.setLockedUntil(LocalDateTime.now().plus(LOCK_DURATION)); + } + credentialRepository.save(credential); + } + + private AuthFlowException invalidCredentials() { + return new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials"); + } + + private String normalizeUsername(String username) { + return username == null ? "" : username.trim().toLowerCase(Locale.ROOT); + } + + private String normalizeEmail(String email) { + if (email == null || email.isBlank()) { + return null; + } + return email.trim().toLowerCase(Locale.ROOT); + } + + private void validateUsername(String username) { + if (!USERNAME_PATTERN.matcher(username).matches()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.username.invalid"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java new file mode 100644 index 00000000..fe788497 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java @@ -0,0 +1,101 @@ +package com.iflytek.skillhub.auth.local; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "local_credential") +public class LocalCredential { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false, length = 128, unique = true) + private String userId; + + @Column(nullable = false, length = 64, unique = true) + private String username; + + @Column(name = "password_hash", nullable = false, length = 255) + private String passwordHash; + + @Column(name = "failed_attempts", nullable = false) + private int failedAttempts; + + @Column(name = "locked_until") + private LocalDateTime lockedUntil; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected LocalCredential() {} + + public LocalCredential(String userId, String username, String passwordHash) { + this.userId = userId; + this.username = username; + this.passwordHash = passwordHash; + this.failedAttempts = 0; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getUsername() { + return username; + } + + public String getPasswordHash() { + return passwordHash; + } + + public int getFailedAttempts() { + return failedAttempts; + } + + public void setFailedAttempts(int failedAttempts) { + this.failedAttempts = failedAttempts; + } + + public LocalDateTime getLockedUntil() { + return lockedUntil; + } + + public void setLockedUntil(LocalDateTime lockedUntil) { + this.lockedUntil = lockedUntil; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java new file mode 100644 index 00000000..ffb65668 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.auth.local; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface LocalCredentialRepository extends JpaRepository { + + Optional findByUsernameIgnoreCase(String username); + + Optional findByUserId(String userId); + + boolean existsByUsernameIgnoreCase(String username); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java new file mode 100644 index 00000000..1afcb86f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.auth.local; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class PasswordPolicyValidator { + + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 128; + private static final int MIN_CHAR_TYPES = 3; + + public List validate(String password) { + List errors = new ArrayList<>(); + if (password == null || password.length() < MIN_LENGTH) { + errors.add("error.auth.local.password.tooShort"); + return errors; + } + if (password.length() > MAX_LENGTH) { + errors.add("error.auth.local.password.tooLong"); + return errors; + } + + int typeCount = 0; + if (password.chars().anyMatch(Character::isLowerCase)) { + typeCount++; + } + if (password.chars().anyMatch(Character::isUpperCase)) { + typeCount++; + } + if (password.chars().anyMatch(Character::isDigit)) { + typeCount++; + } + if (password.chars().anyMatch(ch -> !Character.isLetterOrDigit(ch))) { + typeCount++; + } + if (typeCount < MIN_CHAR_TYPES) { + errors.add("error.auth.local.password.tooWeak"); + } + return errors; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java new file mode 100644 index 00000000..b671f60b --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java @@ -0,0 +1,76 @@ +package com.iflytek.skillhub.auth.merge; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "account_merge_request") +public class AccountMergeRequest { + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_VERIFIED = "VERIFIED"; + public static final String STATUS_COMPLETED = "COMPLETED"; + public static final String STATUS_CANCELLED = "CANCELLED"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "primary_user_id", nullable = false, length = 128) + private String primaryUserId; + + @Column(name = "secondary_user_id", nullable = false, length = 128) + private String secondaryUserId; + + @Column(nullable = false, length = 32) + private String status = STATUS_PENDING; + + @Column(name = "verification_token", length = 255) + private String verificationToken; + + @Column(name = "token_expires_at") + private LocalDateTime tokenExpiresAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected AccountMergeRequest() {} + + public AccountMergeRequest(String primaryUserId, + String secondaryUserId, + String verificationToken, + LocalDateTime tokenExpiresAt) { + this.primaryUserId = primaryUserId; + this.secondaryUserId = secondaryUserId; + this.verificationToken = verificationToken; + this.tokenExpiresAt = tokenExpiresAt; + this.status = STATUS_PENDING; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public String getPrimaryUserId() { return primaryUserId; } + public String getSecondaryUserId() { return secondaryUserId; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getVerificationToken() { return verificationToken; } + public void setVerificationToken(String verificationToken) { this.verificationToken = verificationToken; } + public LocalDateTime getTokenExpiresAt() { return tokenExpiresAt; } + public void setTokenExpiresAt(LocalDateTime tokenExpiresAt) { this.tokenExpiresAt = tokenExpiresAt; } + public LocalDateTime getCompletedAt() { return completedAt; } + public void setCompletedAt(LocalDateTime completedAt) { this.completedAt = completedAt; } + public LocalDateTime getCreatedAt() { return createdAt; } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java new file mode 100644 index 00000000..8c98fb3c --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.merge; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AccountMergeRequestRepository extends JpaRepository { + + Optional findByIdAndPrimaryUserId(Long id, String primaryUserId); + + boolean existsBySecondaryUserIdAndStatus(String secondaryUserId, String status); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java new file mode 100644 index 00000000..9badccb5 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java @@ -0,0 +1,275 @@ +package com.iflytek.skillhub.auth.merge; + +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AccountMergeService { + + private static final Comparator NAMESPACE_ROLE_ORDER = Comparator.comparingInt(role -> switch (role) { + case MEMBER -> 0; + case ADMIN -> 1; + case OWNER -> 2; + }); + + private final AccountMergeRequestRepository mergeRequestRepository; + private final UserAccountRepository userAccountRepository; + private final LocalCredentialRepository localCredentialRepository; + private final IdentityBindingRepository identityBindingRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final ApiTokenRepository apiTokenRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final PasswordEncoder passwordEncoder; + private final SecureRandom secureRandom = new SecureRandom(); + + public AccountMergeService(AccountMergeRequestRepository mergeRequestRepository, + UserAccountRepository userAccountRepository, + LocalCredentialRepository localCredentialRepository, + IdentityBindingRepository identityBindingRepository, + UserRoleBindingRepository userRoleBindingRepository, + ApiTokenRepository apiTokenRepository, + NamespaceMemberRepository namespaceMemberRepository, + PasswordEncoder passwordEncoder) { + this.mergeRequestRepository = mergeRequestRepository; + this.userAccountRepository = userAccountRepository; + this.localCredentialRepository = localCredentialRepository; + this.identityBindingRepository = identityBindingRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.apiTokenRepository = apiTokenRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.passwordEncoder = passwordEncoder; + } + + public record InitiationResult(Long mergeRequestId, String secondaryUserId, String verificationToken, LocalDateTime expiresAt) {} + + @Transactional + public InitiationResult initiate(String primaryUserId, String secondaryIdentifier) { + UserAccount primaryUser = loadActiveUser(primaryUserId); + UserAccount secondaryUser = resolveSecondaryUser(secondaryIdentifier); + validateMergePair(primaryUser, secondaryUser); + + if (mergeRequestRepository.existsBySecondaryUserIdAndStatus( + secondaryUser.getId(), + AccountMergeRequest.STATUS_PENDING + )) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.pendingExists"); + } + + Optional primaryCredential = localCredentialRepository.findByUserId(primaryUserId); + Optional secondaryCredential = localCredentialRepository.findByUserId(secondaryUser.getId()); + if (primaryCredential.isPresent() && secondaryCredential.isPresent()) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.localCredentialConflict"); + } + + String rawToken = generateVerificationToken(); + AccountMergeRequest request = new AccountMergeRequest( + primaryUserId, + secondaryUser.getId(), + passwordEncoder.encode(rawToken), + LocalDateTime.now().plusMinutes(30) + ); + request = mergeRequestRepository.save(request); + return new InitiationResult(request.getId(), secondaryUser.getId(), rawToken, request.getTokenExpiresAt()); + } + + @Transactional + public void verify(String primaryUserId, Long mergeRequestId, String verificationToken) { + AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound")); + if (!AccountMergeRequest.STATUS_PENDING.equals(request.getStatus())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotPending"); + } + if (request.getTokenExpiresAt() == null || request.getTokenExpiresAt().isBefore(LocalDateTime.now())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.tokenExpired"); + } + if (!passwordEncoder.matches(verificationToken, request.getVerificationToken())) { + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.merge.invalidToken"); + } + + loadActiveUser(primaryUserId); + UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + validateMergePair(loadActiveUser(primaryUserId), secondaryUser); + + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + mergeRequestRepository.save(request); + } + + @Transactional + public void confirm(String primaryUserId, Long mergeRequestId) { + AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound")); + if (!AccountMergeRequest.STATUS_VERIFIED.equals(request.getStatus())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotVerified"); + } + + UserAccount primaryUser = loadActiveUser(primaryUserId); + UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + validateMergePair(primaryUser, secondaryUser); + + migrateIdentityBindings(primaryUser.getId(), secondaryUser.getId()); + migrateApiTokens(primaryUser.getId(), secondaryUser.getId()); + migrateUserRoles(primaryUser.getId(), secondaryUser.getId()); + migrateNamespaceMemberships(primaryUser.getId(), secondaryUser.getId()); + migrateLocalCredential(primaryUser.getId(), secondaryUser.getId()); + + if ((primaryUser.getEmail() == null || primaryUser.getEmail().isBlank()) + && secondaryUser.getEmail() != null && !secondaryUser.getEmail().isBlank()) { + primaryUser.setEmail(secondaryUser.getEmail()); + } + userAccountRepository.save(primaryUser); + + secondaryUser.setStatus(UserStatus.MERGED); + secondaryUser.setMergedToUserId(primaryUser.getId()); + userAccountRepository.save(secondaryUser); + + request.setStatus(AccountMergeRequest.STATUS_COMPLETED); + request.setCompletedAt(LocalDateTime.now()); + request.setVerificationToken(null); + mergeRequestRepository.save(request); + } + + private UserAccount resolveSecondaryUser(String identifier) { + String normalized = identifier == null ? "" : identifier.trim(); + if (normalized.isBlank()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.identifierRequired"); + } + if (normalized.contains(":")) { + String[] parts = normalized.split(":", 2); + if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.identifierInvalid"); + } + IdentityBinding binding = identityBindingRepository.findByProviderCodeAndSubject(parts[0], parts[1]) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + return userAccountRepository.findById(binding.getUserId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + } + + LocalCredential credential = localCredentialRepository.findByUsernameIgnoreCase(normalized.toLowerCase(Locale.ROOT)) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + return userAccountRepository.findById(credential.getUserId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + } + + private UserAccount loadActiveUser(String userId) { + UserAccount user = userAccountRepository.findById(userId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.primaryNotFound")); + if (user.getStatus() != UserStatus.ACTIVE) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.primaryNotActive"); + } + return user; + } + + private void validateMergePair(UserAccount primaryUser, UserAccount secondaryUser) { + if (primaryUser.getId().equals(secondaryUser.getId())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.sameAccount"); + } + if (secondaryUser.getStatus() != UserStatus.ACTIVE) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.secondaryNotActive"); + } + } + + private void migrateIdentityBindings(String primaryUserId, String secondaryUserId) { + List bindings = identityBindingRepository.findByUserId(secondaryUserId); + for (IdentityBinding binding : bindings) { + binding.setUserId(primaryUserId); + } + identityBindingRepository.saveAll(bindings); + } + + private void migrateApiTokens(String primaryUserId, String secondaryUserId) { + List tokens = apiTokenRepository.findByUserId(secondaryUserId); + for (ApiToken token : tokens) { + token.setUserId(primaryUserId); + if ("USER".equals(token.getSubjectType())) { + token.setSubjectId(primaryUserId); + } + } + apiTokenRepository.saveAll(tokens); + } + + private void migrateUserRoles(String primaryUserId, String secondaryUserId) { + Set primaryRoleCodes = new HashSet<>(); + for (UserRoleBinding binding : userRoleBindingRepository.findByUserId(primaryUserId)) { + primaryRoleCodes.add(binding.getRole().getCode()); + } + + List secondaryBindings = userRoleBindingRepository.findByUserId(secondaryUserId); + for (UserRoleBinding binding : secondaryBindings) { + Role role = binding.getRole(); + if (!primaryRoleCodes.contains(role.getCode())) { + userRoleBindingRepository.save(new UserRoleBinding(primaryUserId, role)); + primaryRoleCodes.add(role.getCode()); + } + } + userRoleBindingRepository.deleteAll(secondaryBindings); + } + + private void migrateNamespaceMemberships(String primaryUserId, String secondaryUserId) { + List secondaryMemberships = namespaceMemberRepository.findByUserId(secondaryUserId); + for (NamespaceMember secondaryMembership : secondaryMemberships) { + Optional existingPrimaryMembership = namespaceMemberRepository + .findByNamespaceIdAndUserId(secondaryMembership.getNamespaceId(), primaryUserId); + if (existingPrimaryMembership.isPresent()) { + NamespaceMember primaryMembership = existingPrimaryMembership.get(); + if (NAMESPACE_ROLE_ORDER.compare(secondaryMembership.getRole(), primaryMembership.getRole()) > 0) { + primaryMembership.setRole(secondaryMembership.getRole()); + namespaceMemberRepository.save(primaryMembership); + } + namespaceMemberRepository.deleteByNamespaceIdAndUserId( + secondaryMembership.getNamespaceId(), + secondaryUserId + ); + } else { + secondaryMembership.setUserId(primaryUserId); + namespaceMemberRepository.save(secondaryMembership); + } + } + } + + private void migrateLocalCredential(String primaryUserId, String secondaryUserId) { + Optional primaryCredential = localCredentialRepository.findByUserId(primaryUserId); + Optional secondaryCredential = localCredentialRepository.findByUserId(secondaryUserId); + if (primaryCredential.isPresent() && secondaryCredential.isPresent()) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.localCredentialConflict"); + } + secondaryCredential.ifPresent(credential -> { + credential.setUserId(primaryUserId); + localCredentialRepository.save(credential); + }); + } + + private String generateVerificationToken() { + byte[] tokenBytes = new byte[24]; + secureRandom.nextBytes(tokenBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java index dd9afb7f..cc77c92a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java @@ -9,5 +9,6 @@ import java.util.Optional; @Repository public interface ApiTokenRepository extends JpaRepository { Optional findByTokenHash(String tokenHash); + List findByUserId(String userId); List findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java index efad961f..12f48f8f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java @@ -8,4 +8,5 @@ import java.util.Optional; @Repository public interface IdentityBindingRepository extends JpaRepository { Optional findByProviderCodeAndSubject(String providerCode, String subject); + java.util.List findByUserId(String userId); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java index def4ad3b..84e5bd37 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java @@ -11,5 +11,5 @@ import java.util.List; public interface UserRoleBindingRepository extends JpaRepository { List findByUserId(String userId); List findByUserIdIn(Collection userIds); - void deleteByUserId(String userId); + long deleteByUserId(String userId); } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java new file mode 100644 index 00000000..f3d4d659 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java @@ -0,0 +1,94 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.oauth.OAuthClaims; +import com.iflytek.skillhub.auth.oauth.AccountPendingException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class IdentityBindingServiceTest { + + @Mock + private IdentityBindingRepository bindingRepo; + + @Mock + private UserAccountRepository userRepo; + + @Mock + private UserRoleBindingRepository roleBindingRepo; + + @Mock + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + + private IdentityBindingService service; + + @BeforeEach + void setUp() { + service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo, globalNamespaceMembershipService); + } + + @Test + void bindOrCreate_assignsGlobalMembershipForActiveNewUsers() { + OAuthClaims claims = new OAuthClaims( + "github", + "gh_1", + "alice@example.com", + true, + "alice", + Map.of("avatar_url", "https://example.test/a.png") + ); + when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); + + PlatformPrincipal principal = service.bindOrCreate(claims, UserStatus.ACTIVE); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userRepo).save(userCaptor.capture()); + verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); + verify(bindingRepo).save(any(IdentityBinding.class)); + assertThat(principal.displayName()).isEqualTo("alice"); + assertThat(principal.oauthProvider()).isEqualTo("github"); + } + + @Test + void bindOrCreate_doesNotAssignGlobalMembershipForPendingUsers() { + OAuthClaims claims = new OAuthClaims( + "github", + "gh_1", + "alice@example.com", + true, + "alice", + Map.of() + ); + when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + assertThatThrownBy(() -> service.bindOrCreate(claims, UserStatus.PENDING)) + .isInstanceOf(AccountPendingException.class); + + verify(globalNamespaceMembershipService, never()).ensureMember(any()); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java new file mode 100644 index 00000000..8fc505cc --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -0,0 +1,134 @@ +package com.iflytek.skillhub.auth.local; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +class LocalAuthServiceTest { + + @Mock + private LocalCredentialRepository credentialRepository; + + @Mock + private UserAccountRepository userAccountRepository; + + @Mock + private UserRoleBindingRepository userRoleBindingRepository; + + @Mock + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + + @Mock + private PasswordEncoder passwordEncoder; + + private LocalAuthService service; + + @BeforeEach + void setUp() { + service = new LocalAuthService( + credentialRepository, + userAccountRepository, + userRoleBindingRepository, + globalNamespaceMembershipService, + new PasswordPolicyValidator(), + passwordEncoder + ); + } + + @Test + void register_createsUserAndCredential() { + given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false); + given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.empty()); + given(passwordEncoder.encode("Abcd123!")).willReturn("encoded"); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(userRoleBindingRepository.findByUserId(any())).willReturn(List.of()); + + var principal = service.register("Alice", "Abcd123!", "alice@example.com"); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userAccountRepository).save(userCaptor.capture()); + assertThat(userCaptor.getValue().getDisplayName()).isEqualTo("alice"); + assertThat(principal.displayName()).isEqualTo("alice"); + assertThat(principal.email()).isEqualTo("alice@example.com"); + verify(credentialRepository).save(any(LocalCredential.class)); + verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); + } + + @Test + void login_withValidPassword_resetsCounters() { + LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); + credential.setFailedAttempts(3); + credential.setLockedUntil(LocalDateTime.now().minusMinutes(1)); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + Role role = mock(Role.class); + given(role.getCode()).willReturn("USER_ADMIN"); + UserRoleBinding binding = new UserRoleBinding("usr_1", role); + + given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("Abcd123!", "encoded")).willReturn(true); + given(userRoleBindingRepository.findByUserId("usr_1")).willReturn(List.of(binding)); + + var principal = service.login("alice", "Abcd123!"); + + assertThat(credential.getFailedAttempts()).isZero(); + assertThat(credential.getLockedUntil()).isNull(); + assertThat(principal.platformRoles()).containsExactly("USER_ADMIN"); + } + + @Test + void login_withInvalidPassword_incrementsCounter() { + LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + + given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("bad", "encoded")).willReturn(false); + + assertThatThrownBy(() -> service.login("alice", "bad")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + assertThat(credential.getFailedAttempts()).isEqualTo(1); + verify(credentialRepository).save(credential); + } + + @Test + void login_withDisabledAccount_fails() { + LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + user.setStatus(UserStatus.DISABLED); + + given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + + assertThatThrownBy(() -> service.login("alice", "Abcd123!")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.local.accountDisabled"); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java new file mode 100644 index 00000000..1b5eb8b7 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java @@ -0,0 +1,38 @@ +package com.iflytek.skillhub.auth.local; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PasswordPolicyValidatorTest { + + private final PasswordPolicyValidator validator = new PasswordPolicyValidator(); + + @Test + void validPassword_passes() { + assertThat(validator.validate("Abcdef1!")).isEmpty(); + } + + @Test + void tooShort_fails() { + assertThat(validator.validate("Ab1!xyz")).containsExactly("error.auth.local.password.tooShort"); + } + + @Test + void tooLong_fails() { + assertThat(validator.validate("A".repeat(129))).containsExactly("error.auth.local.password.tooLong"); + } + + @Test + void twoCharTypes_fails() { + assertThat(validator.validate("abcdefgh1")).containsExactly("error.auth.local.password.tooWeak"); + } + + @ParameterizedTest + @ValueSource(strings = {"Abcdefg1", "Abcdef1!", "abcdef1!", "ABCDEF1!"}) + void threeCharTypes_pass(String password) { + assertThat(validator.validate(password)).isEmpty(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java new file mode 100644 index 00000000..fa38a89d --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java @@ -0,0 +1,178 @@ +package com.iflytek.skillhub.auth.merge; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +class AccountMergeServiceTest { + + @Mock + private AccountMergeRequestRepository mergeRequestRepository; + @Mock + private UserAccountRepository userAccountRepository; + @Mock + private LocalCredentialRepository localCredentialRepository; + @Mock + private IdentityBindingRepository identityBindingRepository; + @Mock + private UserRoleBindingRepository userRoleBindingRepository; + @Mock + private ApiTokenRepository apiTokenRepository; + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private PasswordEncoder passwordEncoder; + + private AccountMergeService service; + + @BeforeEach + void setUp() { + service = new AccountMergeService( + mergeRequestRepository, + userAccountRepository, + localCredentialRepository, + identityBindingRepository, + userRoleBindingRepository, + apiTokenRepository, + namespaceMemberRepository, + passwordEncoder + ); + } + + @Test + void initiate_withLocalUsername_createsPendingRequest() { + UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); + UserAccount secondary = new UserAccount("usr_secondary", "secondary", "secondary@example.com", null); + LocalCredential secondaryCredential = new LocalCredential("usr_secondary", "secondary", "hash"); + given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); + given(localCredentialRepository.findByUsernameIgnoreCase("secondary")).willReturn(Optional.of(secondaryCredential)); + given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); + given(mergeRequestRepository.existsBySecondaryUserIdAndStatus("usr_secondary", AccountMergeRequest.STATUS_PENDING)) + .willReturn(false); + given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty()); + given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.of(secondaryCredential)); + given(passwordEncoder.encode(any())).willReturn("encoded-token"); + given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); + + var result = service.initiate("usr_primary", "secondary"); + + assertThat(result.secondaryUserId()).isEqualTo("usr_secondary"); + assertThat(result.verificationToken()).isNotBlank(); + verify(mergeRequestRepository).save(any(AccountMergeRequest.class)); + } + + @Test + void verify_marksRequestVerifiedWhenTokenMatches() throws Exception { + UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); + UserAccount secondary = new UserAccount("usr_secondary", "secondary", "", null); + AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); + + given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); + given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); + given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); + given(passwordEncoder.matches("raw-token", "encoded")).willReturn(true); + given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); + + service.verify("usr_primary", 7L, "raw-token"); + + assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_VERIFIED); + verify(mergeRequestRepository).save(request); + } + + @Test + void confirm_migratesBindingsRolesTokensAndMemberships() throws Exception { + UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); + UserAccount secondary = new UserAccount("usr_secondary", "secondary", "", null); + AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + Role role = mock(Role.class); + given(role.getCode()).willReturn("AUDITOR"); + UserRoleBinding secondaryRole = new UserRoleBinding("usr_secondary", role); + IdentityBinding binding = new IdentityBinding("usr_secondary", "github", "gh_123", "secondary"); + ApiToken token = new ApiToken("usr_secondary", "cli", "sk_123", "hash", "[]"); + NamespaceMember secondaryMembership = new NamespaceMember(1L, "usr_secondary", NamespaceRole.ADMIN); + + given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); + given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); + given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); + given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(identityBindingRepository.findByUserId("usr_secondary")).willReturn(List.of(binding)); + given(apiTokenRepository.findByUserId("usr_secondary")).willReturn(List.of(token)); + given(userRoleBindingRepository.findByUserId("usr_primary")).willReturn(List.of()); + given(userRoleBindingRepository.findByUserId("usr_secondary")).willReturn(List.of(secondaryRole)); + given(namespaceMemberRepository.findByUserId("usr_secondary")).willReturn(List.of(secondaryMembership)); + given(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_primary")).willReturn(Optional.empty()); + given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty()); + given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.empty()); + + service.confirm("usr_primary", 7L); + + assertThat(binding.getUserId()).isEqualTo("usr_primary"); + assertThat(token.getUserId()).isEqualTo("usr_primary"); + assertThat(token.getSubjectId()).isEqualTo("usr_primary"); + assertThat(secondaryMembership.getUserId()).isEqualTo("usr_primary"); + assertThat(secondary.getStatus()).isEqualTo(com.iflytek.skillhub.domain.user.UserStatus.MERGED); + assertThat(secondary.getMergedToUserId()).isEqualTo("usr_primary"); + assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_COMPLETED); + assertThat(request.getVerificationToken()).isNull(); + verify(userRoleBindingRepository).save(any(UserRoleBinding.class)); + verify(userRoleBindingRepository).deleteAll(List.of(secondaryRole)); + } + + @Test + void verify_rejectsInvalidToken() throws Exception { + AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); + given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); + given(passwordEncoder.matches("bad-token", "encoded")).willReturn(false); + + assertThatThrownBy(() -> service.verify("usr_primary", 7L, "bad-token")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.merge.invalidToken"); + + verify(identityBindingRepository, never()).saveAll(any()); + } + + private AccountMergeRequest request(String primaryUserId, String secondaryUserId, String token) throws Exception { + AccountMergeRequest request = new AccountMergeRequest( + primaryUserId, + secondaryUserId, + token, + LocalDateTime.now().plusMinutes(10) + ); + Field idField = AccountMergeRequest.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(request, 7L); + return request; + } +} diff --git a/server/skillhub-domain/pom.xml b/server/skillhub-domain/pom.xml index 425105bc..de1527d1 100644 --- a/server/skillhub-domain/pom.xml +++ b/server/skillhub-domain/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.2 skillhub-domain diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java new file mode 100644 index 00000000..61352599 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java @@ -0,0 +1,82 @@ +package com.iflytek.skillhub.domain.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "audit_log") +public class AuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "actor_user_id", length = 128) + private String actorUserId; + + @Column(nullable = false, length = 64) + private String action; + + @Column(name = "target_type", length = 64) + private String targetType; + + @Column(name = "target_id") + private Long targetId; + + @Column(name = "request_id", length = 64) + private String requestId; + + @Column(name = "client_ip", length = 64) + private String clientIp; + + @Column(name = "user_agent", length = 512) + private String userAgent; + + @Column(name = "detail_json", columnDefinition = "jsonb") + private String detailJson; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected AuditLog() {} + + public AuditLog(String actorUserId, + String action, + String targetType, + Long targetId, + String requestId, + String clientIp, + String userAgent, + String detailJson) { + this.actorUserId = actorUserId; + this.action = action; + this.targetType = targetType; + this.targetId = targetId; + this.requestId = requestId; + this.clientIp = clientIp; + this.userAgent = userAgent; + this.detailJson = detailJson; + } + + @PrePersist + void prePersist() { + this.createdAt = Instant.now(); + } + + public Long getId() { return id; } + public String getActorUserId() { return actorUserId; } + public String getAction() { return action; } + public String getTargetType() { return targetType; } + public Long getTargetId() { return targetId; } + public String getRequestId() { return requestId; } + public String getClientIp() { return clientIp; } + public String getUserAgent() { return userAgent; } + public String getDetailJson() { return detailJson; } + public Instant getCreatedAt() { return createdAt; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java new file mode 100644 index 00000000..b2f74ef0 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.domain.audit; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Service +public class AuditLogQueryService { + + private final AuditLogRepository auditLogRepository; + + public AuditLogQueryService(AuditLogRepository auditLogRepository) { + this.auditLogRepository = auditLogRepository; + } + + public Page list(int page, int size, String actorUserId, String action) { + return auditLogRepository.search(actorUserId, action, PageRequest.of(page, size)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java new file mode 100644 index 00000000..bd967a41 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.domain.audit; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface AuditLogRepository { + AuditLog save(AuditLog auditLog); + Page search(String actorUserId, String action, Pageable pageable); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java new file mode 100644 index 00000000..2fb97e6f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.domain.audit; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AuditLogService { + + private final AuditLogRepository auditLogRepository; + + public AuditLogService(AuditLogRepository auditLogRepository) { + this.auditLogRepository = auditLogRepository; + } + + @Transactional + public AuditLog record(String actorUserId, + String action, + String targetType, + Long targetId, + String requestId, + String clientIp, + String userAgent, + String detailJson) { + return auditLogRepository.save(new AuditLog( + actorUserId, + action, + targetType, + targetId, + requestId, + clientIp, + userAgent, + detailJson + )); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java new file mode 100644 index 00000000..947d414f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class GlobalNamespaceMembershipService { + + private static final String GLOBAL_NAMESPACE_SLUG = "global"; + + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + + public GlobalNamespaceMembershipService(NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository) { + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + } + + @Transactional + public void ensureMember(String userId) { + Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE_SLUG) + .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); + + namespaceMemberRepository.findByNamespaceIdAndUserId(globalNamespace.getId(), userId) + .orElseGet(() -> namespaceMemberRepository.save( + new NamespaceMember(globalNamespace.getId(), userId, NamespaceRole.MEMBER) + )); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index 919d847f..aaf3d705 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -46,6 +46,45 @@ public class PromotionService { this.eventPublisher = eventPublisher; } + @Transactional + public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId, + Long targetNamespaceId, String userId, + Map userNamespaceRoles, + Set platformRoles) { + Skill sourceSkill = skillRepository.findById(sourceSkillId) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId)); + + SkillVersion sourceVersion = skillVersionRepository.findById(sourceVersionId) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", sourceVersionId)); + + if (!sourceVersion.getSkillId().equals(sourceSkillId)) { + throw new DomainBadRequestException("promotion.version_skill_mismatch", sourceVersionId, sourceSkillId); + } + + if (sourceVersion.getStatus() != SkillVersionStatus.PUBLISHED) { + 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)); + + if (targetNamespace.getType() != NamespaceType.GLOBAL) { + throw new DomainBadRequestException("promotion.target_not_global", targetNamespaceId); + } + + promotionRequestRepository.findBySourceVersionIdAndStatus(sourceVersionId, ReviewTaskStatus.PENDING) + .ifPresent(existing -> { + throw new DomainBadRequestException("promotion.duplicate_pending", sourceVersionId); + }); + + PromotionRequest request = new PromotionRequest(sourceSkillId, sourceVersionId, targetNamespaceId, userId); + return promotionRequestRepository.save(request); + } + @Transactional public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId, Long targetNamespaceId, String userId, @@ -177,4 +216,8 @@ public class PromotionService { return promotionRequestRepository.findById(promotionId).orElse(request); } + + public boolean canViewPromotion(PromotionRequest request, String userId, Set platformRoles) { + return permissionChecker.canViewPromotion(request, userId, platformRoles); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index 4983a4a7..c3cb3665 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -19,45 +19,63 @@ public class ReviewPermissionChecker { || role == NamespaceRole.MEMBER; } - public boolean canManageNamespaceReviews(Long namespaceId, - NamespaceType namespaceType, - Map userNamespaceRoles, - Set platformRoles) { + public boolean canReview(ReviewTask task, + String userId, + NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + if (task.getSubmittedBy().equals(userId)) { + return false; + } + return canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles); + } + + public boolean canSubmitForReview(Skill skill, + String userId, + Map userNamespaceRoles, + Set platformRoles) { + if (skill.getOwnerId().equals(userId)) { + return true; + } + if (hasPlatformReviewRole(platformRoles)) { + return true; + } + + NamespaceRole role = userNamespaceRoles.get(skill.getNamespaceId()); + return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; + } + + public boolean canViewReview(ReviewTask task, + String userId, + NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + if (task.getSubmittedBy().equals(userId)) { + return true; + } + return canReview(task, userId, namespaceType, userNamespaceRoles, platformRoles); + } + + public boolean canReviewNamespace(Long namespaceId, + NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + if (hasPlatformReviewRole(platformRoles)) { + return true; + } if (namespaceType == NamespaceType.GLOBAL) { - return hasPlatformReviewRole(platformRoles); + return false; } NamespaceRole role = userNamespaceRoles.get(namespaceId); return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; } - /** - * Check if a user can review a ReviewTask. - * - * @param task the review task - * @param userId the reviewer's user ID - * @param namespaceType the type of the namespace - * @param userNamespaceRoles user's roles keyed by namespace ID - * @param platformRoles user's platform-level roles - * @return true if the user is allowed to review - */ - public boolean canReview(ReviewTask task, - String userId, - NamespaceType namespaceType, - Map userNamespaceRoles, - Set platformRoles) { - // Cannot review own submission - if (task.getSubmittedBy().equals(userId)) { - return false; - } - - // Global namespace: only SKILL_ADMIN or SUPER_ADMIN - return canManageNamespaceReviews( - task.getNamespaceId(), - namespaceType, - userNamespaceRoles, - platformRoles - ); + public boolean canManageNamespaceReviews(Long namespaceId, + NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + return canReviewNamespace(namespaceId, namespaceType, userNamespaceRoles, platformRoles); } public boolean canReadReview(ReviewTask task, @@ -65,30 +83,22 @@ public class ReviewPermissionChecker { NamespaceType namespaceType, Map userNamespaceRoles, Set platformRoles) { - return task.getSubmittedBy().equals(userId) - || canManageNamespaceReviews( - task.getNamespaceId(), - namespaceType, - userNamespaceRoles, - platformRoles - ); + return canViewReview(task, userId, namespaceType, userNamespaceRoles, platformRoles); + } + + public boolean canSubmitPromotion(Skill sourceSkill, + String userId, + Map userNamespaceRoles, + Set platformRoles) { + return canSubmitForReview(sourceSkill, userId, userNamespaceRoles, platformRoles); } public boolean canSubmitPromotion(Skill sourceSkill, String userId, Map userNamespaceRoles) { - if (sourceSkill.getOwnerId().equals(userId)) { - return true; - } - - NamespaceRole role = userNamespaceRoles.get(sourceSkill.getNamespaceId()); - return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; + return canSubmitPromotion(sourceSkill, userId, userNamespaceRoles, Set.of()); } - /** - * Check if a user can review a PromotionRequest. - * Only SKILL_ADMIN or SUPER_ADMIN, and not own. - */ public boolean canReviewPromotion( PromotionRequest request, String userId, @@ -99,6 +109,15 @@ public class ReviewPermissionChecker { return hasPlatformReviewRole(platformRoles); } + public boolean canViewPromotion(PromotionRequest request, + String userId, + Set platformRoles) { + if (request.getSubmittedBy().equals(userId)) { + return true; + } + return canReviewPromotion(request, userId, platformRoles); + } + public boolean canListPendingPromotions(Set platformRoles) { return hasPlatformReviewRole(platformRoles); } @@ -106,8 +125,7 @@ public class ReviewPermissionChecker { public boolean canReadPromotion(PromotionRequest request, String userId, Set platformRoles) { - return request.getSubmittedBy().equals(userId) - || canListPendingPromotions(platformRoles); + return canViewPromotion(request, userId, platformRoles); } private boolean hasPlatformReviewRole(Set platformRoles) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index 205a2054..4d654eea 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -51,6 +51,36 @@ public class ReviewService { this.objectMapper = objectMapper; } + @Transactional + public ReviewTask submitReview(Long skillVersionId, + String userId, + Map userNamespaceRoles, + Set platformRoles) { + SkillVersion skillVersion = skillVersionRepository.findById(skillVersionId) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", skillVersionId)); + + Skill skill = skillRepository.findById(skillVersion.getSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + + if (!permissionChecker.canSubmitForReview(skill, userId, userNamespaceRoles, platformRoles)) { + throw new DomainForbiddenException("review.submit.no_permission"); + } + + if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) { + throw new DomainBadRequestException("review.submit.not_draft", skillVersionId); + } + + skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW); + skillVersionRepository.save(skillVersion); + + ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId); + try { + return reviewTaskRepository.save(task); + } catch (DataIntegrityViolationException e) { + throw new DomainBadRequestException("review.submit.duplicate", skillVersionId); + } + } + @Transactional public ReviewTask submitReview(Long skillVersionId, String userId, @@ -176,6 +206,22 @@ public class ReviewService { skillVersionRepository.save(skillVersion); } + public boolean canReviewNamespace(ReviewTask task, + String userId, + com.iflytek.skillhub.domain.namespace.NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + return permissionChecker.canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles); + } + + public boolean canViewReview(ReviewTask task, + String userId, + com.iflytek.skillhub.domain.namespace.NamespaceType namespaceType, + Map userNamespaceRoles, + Set platformRoles) { + return permissionChecker.canViewReview(task, userId, namespaceType, userNamespaceRoles, platformRoles); + } + private void applyPublishedMetadata(Skill skill, SkillVersion skillVersion) { String metadataJson = skillVersion.getParsedMetadataJson(); if (metadataJson == null || metadataJson.isBlank()) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java index 9dfd545c..9d3a3c49 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java @@ -8,6 +8,7 @@ public interface ReviewTaskRepository { ReviewTask save(ReviewTask reviewTask); Optional findById(Long id); Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); + Page findByStatus(ReviewTaskStatus status, Pageable pageable); Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); void delete(ReviewTask reviewTask); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java index 32598bf3..3b5c2570 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java @@ -44,6 +44,15 @@ public class Skill { @Column(name = "download_count", nullable = false) private Long downloadCount = 0L; + @Column(nullable = false) + private boolean hidden = false; + + @Column(name = "hidden_at") + private LocalDateTime hiddenAt; + + @Column(name = "hidden_by", length = 128) + private String hiddenBy; + @Column(name = "star_count", nullable = false) private Integer starCount = 0; @@ -132,6 +141,18 @@ public class Skill { return downloadCount; } + public boolean isHidden() { + return hidden; + } + + public LocalDateTime getHiddenAt() { + return hiddenAt; + } + + public String getHiddenBy() { + return hiddenBy; + } + public Integer getStarCount() { return starCount; } @@ -192,4 +213,16 @@ public class Skill { public void setUpdatedBy(String updatedBy) { this.updatedBy = updatedBy; } + + public void setHidden(boolean hidden) { + this.hidden = hidden; + } + + public void setHiddenAt(LocalDateTime hiddenAt) { + this.hiddenAt = hiddenAt; + } + + public void setHiddenBy(String hiddenBy) { + this.hiddenBy = hiddenBy; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java index a05c425f..dddbc29e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java @@ -43,6 +43,15 @@ public class SkillVersion { @Column(name = "published_at") private LocalDateTime publishedAt; + @Column(name = "yanked_at") + private LocalDateTime yankedAt; + + @Column(name = "yanked_by", length = 128) + private String yankedBy; + + @Column(name = "yank_reason", columnDefinition = "TEXT") + private String yankReason; + @Column(name = "created_by", nullable = false) private String createdBy; @@ -105,6 +114,18 @@ public class SkillVersion { return publishedAt; } + public LocalDateTime getYankedAt() { + return yankedAt; + } + + public String getYankedBy() { + return yankedBy; + } + + public String getYankReason() { + return yankReason; + } + public String getCreatedBy() { return createdBy; } @@ -141,4 +162,16 @@ public class SkillVersion { public void setPublishedAt(LocalDateTime publishedAt) { this.publishedAt = publishedAt; } + + public void setYankedAt(LocalDateTime yankedAt) { + this.yankedAt = yankedAt; + } + + public void setYankedBy(String yankedBy) { + this.yankedBy = yankedBy; + } + + public void setYankReason(String yankReason) { + this.yankReason = yankReason; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java index 57022259..9cd17cda 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java @@ -4,5 +4,6 @@ public enum SkillVersionStatus { DRAFT, PENDING_REVIEW, PUBLISHED, - REJECTED + REJECTED, + YANKED } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 31ee1a53..2b4fef49 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -13,6 +13,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.io.InputStream; +import java.time.Duration; import java.util.Map; @Service @@ -47,7 +48,8 @@ public class SkillDownloadService { InputStream content, String filename, long contentLength, - String contentType + String contentType, + String presignedUrl ) {} public DownloadResult downloadLatest( @@ -137,14 +139,15 @@ public class SkillDownloadService { } ObjectMetadata metadata = objectStorageService.getMetadata(storageKey); - InputStream content = objectStorageService.getObject(storageKey); + String presignedUrl = objectStorageService.generatePresignedUrl(storageKey, Duration.ofMinutes(10)); + InputStream content = presignedUrl == null ? objectStorageService.getObject(storageKey) : null; // Publish download event eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); String filename = String.format("%s-%s.zip", skill.getSlug(), version.getVersion()); - return new DownloadResult(content, filename, metadata.size(), metadata.contentType()); + return new DownloadResult(content, filename, metadata.size(), metadata.contentType(), presignedUrl); } private Namespace findNamespace(String slug) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java new file mode 100644 index 00000000..6f09cb32 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -0,0 +1,74 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import java.time.LocalDateTime; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SkillGovernanceService { + + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final AuditLogService auditLogService; + + public SkillGovernanceService(SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + AuditLogService auditLogService) { + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.auditLogService = auditLogService; + } + + @Transactional + public Skill hideSkill(Long skillId, String actorUserId, String clientIp, String userAgent, String reason) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + skill.setHidden(true); + skill.setHiddenAt(LocalDateTime.now()); + skill.setHiddenBy(actorUserId); + skill.setUpdatedBy(actorUserId); + Skill saved = skillRepository.save(skill); + auditLogService.record(actorUserId, "HIDE_SKILL", "SKILL", skillId, null, clientIp, userAgent, jsonReason(reason)); + return saved; + } + + @Transactional + public Skill unhideSkill(Long skillId, String actorUserId, String clientIp, String userAgent) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + skill.setHidden(false); + skill.setHiddenAt(null); + skill.setHiddenBy(null); + skill.setUpdatedBy(actorUserId); + Skill saved = skillRepository.save(skill); + auditLogService.record(actorUserId, "UNHIDE_SKILL", "SKILL", skillId, null, clientIp, userAgent, null); + return saved; + } + + @Transactional + public SkillVersion yankVersion(Long versionId, String actorUserId, String clientIp, String userAgent, String reason) { + SkillVersion version = skillVersionRepository.findById(versionId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.version.notFound", versionId)); + version.setStatus(SkillVersionStatus.YANKED); + version.setYankedAt(LocalDateTime.now()); + version.setYankedBy(actorUserId); + version.setYankReason(reason); + SkillVersion saved = skillVersionRepository.save(version); + auditLogService.record(actorUserId, "YANK_SKILL_VERSION", "SKILL_VERSION", versionId, null, clientIp, userAgent, jsonReason(reason)); + return saved; + } + + private String jsonReason(String reason) { + if (reason == null || reason.isBlank()) { + return null; + } + return "{\"reason\":\"" + reason.replace("\"", "\\\"") + "\"}"; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 997b2982..e4953ebf 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -61,6 +61,9 @@ public class SkillQueryService { String status, Long downloadCount, Integer starCount, + java.math.BigDecimal ratingAvg, + Integer ratingCount, + boolean hidden, String latestVersion, Long namespaceId ) {} @@ -120,6 +123,9 @@ public class SkillQueryService { skill.getStatus().name(), skill.getDownloadCount(), skill.getStarCount(), + skill.getRatingAvg(), + skill.getRatingCount(), + skill.isHidden(), latestVersion, skill.getNamespaceId() ); @@ -230,8 +236,13 @@ public class SkillQueryService { return objectStorageService.getObject(file.getStorageKey()); } - public Page listVersions(String namespaceSlug, String skillSlug, Pageable pageable) { + public Page listVersions(String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Pageable pageable) { Skill skill = findSkill(namespaceSlug, skillSlug); + assertPublishedAccessible(skill, currentUserId, userNsRoles); List publishedVersions = skillVersionRepository.findBySkillIdAndStatus( skill.getId(), SkillVersionStatus.PUBLISHED); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java index 39ca41f9..a6589562 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java @@ -22,24 +22,33 @@ public class SkillTagService { private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; private final SkillTagRepository skillTagRepository; + private final VisibilityChecker visibilityChecker; public SkillTagService( NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, - SkillTagRepository skillTagRepository) { + SkillTagRepository skillTagRepository, + VisibilityChecker visibilityChecker) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillTagRepository = skillTagRepository; + this.visibilityChecker = visibilityChecker; } - public List listTags(String namespaceSlug, String skillSlug) { + public List listTags(String namespaceSlug, + String skillSlug, + String currentUserId, + java.util.Map userNamespaceRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + if (!visibilityChecker.canAccess(skill, currentUserId, userNamespaceRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skillSlug); + } List tags = new java.util.ArrayList<>(skillTagRepository.findBySkillId(skill.getId())); if (skill.getLatestVersionId() != null) { @@ -48,6 +57,10 @@ public class SkillTagService { return tags; } + public List listTags(String namespaceSlug, String skillSlug) { + return listTags(namespaceSlug, skillSlug, null, java.util.Map.of()); + } + @Transactional public SkillTag createOrMoveTag( String namespaceSlug, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java index 950417fc..142a8b2c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java @@ -11,9 +11,33 @@ import java.util.Set; public class SkillPackageValidator { private final SkillMetadataParser metadataParser; + private final int maxFileCount; + private final long maxSingleFileSize; + private final long maxTotalPackageSize; + private final Set allowedExtensions; public SkillPackageValidator(SkillMetadataParser metadataParser) { + this( + metadataParser, + SkillPackagePolicy.MAX_FILE_COUNT, + SkillPackagePolicy.MAX_SINGLE_FILE_SIZE, + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE, + SkillPackagePolicy.ALLOWED_EXTENSIONS + ); + } + + public SkillPackageValidator(SkillMetadataParser metadataParser, + int maxFileCount, + long maxSingleFileSize, + long maxTotalPackageSize, + Set allowedExtensions) { this.metadataParser = metadataParser; + this.maxFileCount = maxFileCount; + this.maxSingleFileSize = maxSingleFileSize; + this.maxTotalPackageSize = maxTotalPackageSize; + this.allowedExtensions = allowedExtensions.stream() + .map(String::toLowerCase) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); } public ValidationResult validate(List entries) { @@ -34,7 +58,7 @@ public class SkillPackageValidator { errors.add("Duplicate package entry path: " + normalizedPath); } - if (!SkillPackagePolicy.hasAllowedExtension(normalizedPath)) { + if (!hasAllowedExtension(normalizedPath)) { errors.add("Disallowed file extension: " + normalizedPath); } @@ -61,23 +85,27 @@ public class SkillPackageValidator { } // 3. Check file count - if (entries.size() > SkillPackagePolicy.MAX_FILE_COUNT) { - errors.add("Too many files: " + entries.size() + " (max: " + SkillPackagePolicy.MAX_FILE_COUNT + ")"); + if (entries.size() > maxFileCount) { + errors.add("Too many files: " + entries.size() + " (max: " + maxFileCount + ")"); } // 4. Check single file size for (PackageEntry entry : entries) { - if (entry.size() > SkillPackagePolicy.MAX_SINGLE_FILE_SIZE) { - errors.add("File too large: " + entry.path() + " (" + entry.size() + " bytes, max: " + SkillPackagePolicy.MAX_SINGLE_FILE_SIZE + ")"); + if (entry.size() > maxSingleFileSize) { + errors.add("File too large: " + entry.path() + " (" + entry.size() + " bytes, max: " + maxSingleFileSize + ")"); } } // 5. Check total package size long totalSize = entries.stream().mapToLong(PackageEntry::size).sum(); - if (totalSize > SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE) { - errors.add("Package too large: " + totalSize + " bytes (max: " + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE + ")"); + if (totalSize > maxTotalPackageSize) { + errors.add("Package too large: " + totalSize + " bytes (max: " + maxTotalPackageSize + ")"); } return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors); } + + private boolean hasAllowedExtension(String normalizedPath) { + return allowedExtensions.stream().anyMatch(normalizedPath::endsWith); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java index 814c1ff7..7d8a6235 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -11,16 +13,20 @@ import java.util.Optional; @Service public class SkillRatingService { private final SkillRatingRepository ratingRepository; + private final SkillRepository skillRepository; private final ApplicationEventPublisher eventPublisher; public SkillRatingService(SkillRatingRepository ratingRepository, + SkillRepository skillRepository, ApplicationEventPublisher eventPublisher) { this.ratingRepository = ratingRepository; + this.skillRepository = skillRepository; this.eventPublisher = eventPublisher; } @Transactional public void rate(Long skillId, String userId, short score) { + ensureSkillExists(skillId); if (score < 1 || score > 5) { throw new DomainBadRequestException("error.rating.score.invalid"); } @@ -35,7 +41,14 @@ public class SkillRatingService { } public Optional getUserRating(Long skillId, String userId) { + ensureSkillExists(skillId); return ratingRepository.findBySkillIdAndUserId(skillId, userId) .map(SkillRating::getScore); } + + private void ensureSkillExists(Long skillId) { + if (skillRepository.findById(skillId).isEmpty()) { + throw new DomainNotFoundException("skill.not_found", skillId); + } + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java index 2576d457..b1dd49bc 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.domain.social; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.social.event.SkillStarredEvent; import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent; import org.springframework.context.ApplicationEventPublisher; @@ -9,16 +11,20 @@ import org.springframework.transaction.annotation.Transactional; @Service public class SkillStarService { private final SkillStarRepository starRepository; + private final SkillRepository skillRepository; private final ApplicationEventPublisher eventPublisher; public SkillStarService(SkillStarRepository starRepository, + SkillRepository skillRepository, ApplicationEventPublisher eventPublisher) { this.starRepository = starRepository; + this.skillRepository = skillRepository; this.eventPublisher = eventPublisher; } @Transactional public void star(Long skillId, String userId) { + ensureSkillExists(skillId); if (starRepository.findBySkillIdAndUserId(skillId, userId).isPresent()) { return; // idempotent } @@ -28,6 +34,7 @@ public class SkillStarService { @Transactional public void unstar(Long skillId, String userId) { + ensureSkillExists(skillId); starRepository.findBySkillIdAndUserId(skillId, userId).ifPresent(star -> { starRepository.delete(star); eventPublisher.publishEvent(new SkillUnstarredEvent(skillId, userId)); @@ -35,6 +42,13 @@ public class SkillStarService { } public boolean isStarred(Long skillId, String userId) { + ensureSkillExists(skillId); return starRepository.findBySkillIdAndUserId(skillId, userId).isPresent(); } + + private void ensureSkillExists(Long skillId) { + if (skillRepository.findById(skillId).isEmpty()) { + throw new DomainNotFoundException("skill.not_found", skillId); + } + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java index d9448334..d2163c56 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java @@ -1,8 +1,15 @@ package com.iflytek.skillhub.domain.user; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; import java.util.Optional; public interface UserAccountRepository { Optional findById(String id); + List findByIdIn(List ids); + Optional findByEmailIgnoreCase(String email); + Page search(String keyword, UserStatus status, Pageable pageable); UserAccount save(UserAccount user); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java new file mode 100644 index 00000000..c4e211bb --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java @@ -0,0 +1,71 @@ +package com.iflytek.skillhub.domain.namespace; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.lang.reflect.Field; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class GlobalNamespaceMembershipServiceTest { + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + + private GlobalNamespaceMembershipService service; + + @BeforeEach + void setUp() { + service = new GlobalNamespaceMembershipService(namespaceRepository, namespaceMemberRepository); + } + + @Test + void ensureMember_createsGlobalMembershipWhenMissing() throws Exception { + Namespace global = new Namespace("global", "Global", "system"); + setNamespaceId(global, 1L); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.empty()); + + service.ensureMember("usr_1"); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository).save(memberCaptor.capture()); + assertThat(memberCaptor.getValue().getNamespaceId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().getUserId()).isEqualTo("usr_1"); + assertThat(memberCaptor.getValue().getRole()).isEqualTo(NamespaceRole.MEMBER); + } + + @Test + void ensureMember_keepsExistingGlobalMembership() throws Exception { + Namespace global = new Namespace("global", "Global", "system"); + setNamespaceId(global, 1L); + NamespaceMember existing = new NamespaceMember(1L, "usr_1", NamespaceRole.ADMIN); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.of(existing)); + + service.ensureMember("usr_1"); + + verify(namespaceMemberRepository, never()).save(any()); + } + + private void setNamespaceId(Namespace namespace, Long id) throws Exception { + Field field = Namespace.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(namespace, id); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java index 48a3aadd..3c50f3bb 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java @@ -68,11 +68,19 @@ class ReviewPermissionCheckerTest { @Test void skillAdminCannotReviewTeamSkill() { ReviewTask task = new ReviewTask(1L, 10L, "user-2"); - assertFalse(checker.canReview(task, "user-1", + assertTrue(checker.canReview(task, "user-1", NamespaceType.TEAM, Map.of(), Set.of("SKILL_ADMIN"))); } + @Test + void superAdminCanReviewTeamSkill() { + ReviewTask task = new ReviewTask(1L, 10L, "user-2"); + assertTrue(checker.canReview(task, "user-1", + NamespaceType.TEAM, + Map.of(), Set.of("SUPER_ADMIN"))); + } + @Test void nonAdminCannotReviewGlobalSkill() { ReviewTask task = new ReviewTask(1L, 1L, "user-2"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java new file mode 100644 index 00000000..9df6b643 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -0,0 +1,64 @@ +package com.iflytek.skillhub.domain.skill.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.given; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SkillGovernanceServiceTest { + + @Mock + private SkillRepository skillRepository; + @Mock + private SkillVersionRepository skillVersionRepository; + @Mock + private AuditLogService auditLogService; + + private SkillGovernanceService service; + + @BeforeEach + void setUp() { + service = new SkillGovernanceService(skillRepository, skillVersionRepository, auditLogService); + } + + @Test + void hideSkill_marksSkillHidden() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); + given(skillRepository.save(skill)).willReturn(skill); + + Skill result = service.hideSkill(10L, "admin", "127.0.0.1", "JUnit", "policy"); + + assertThat(result.isHidden()).isTrue(); + assertThat(result.getHiddenBy()).isEqualTo("admin"); + verify(auditLogService).record("admin", "HIDE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reason\":\"policy\"}"); + } + + @Test + void yankVersion_setsYankedStatus() { + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + version.setStatus(SkillVersionStatus.PUBLISHED); + given(skillVersionRepository.findById(22L)).willReturn(Optional.of(version)); + given(skillVersionRepository.save(version)).willReturn(version); + + SkillVersion result = service.yankVersion(22L, "admin", "127.0.0.1", "JUnit", "broken"); + + assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.YANKED); + assertThat(result.getYankedBy()).isEqualTo("admin"); + verify(auditLogService).record("admin", "YANK_SKILL_VERSION", "SKILL_VERSION", 22L, null, "127.0.0.1", "JUnit", "{\"reason\":\"broken\"}"); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java index 85b5f317..223b24dc 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java @@ -35,6 +35,8 @@ class SkillTagServiceTest { private SkillVersionRepository skillVersionRepository; @Mock private SkillTagRepository skillTagRepository; + @Mock + private VisibilityChecker visibilityChecker; private SkillTagService service; @@ -45,7 +47,8 @@ class SkillTagServiceTest { namespaceMemberRepository, skillRepository, skillVersionRepository, - skillTagRepository + skillTagRepository, + visibilityChecker ); } @@ -174,9 +177,10 @@ class SkillTagServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); when(skillTagRepository.findBySkillId(1L)).thenReturn(List.of(tag1, tag2)); + when(visibilityChecker.canAccess(eq(skill), isNull(), eq(java.util.Map.of()))).thenReturn(true); // Act - List result = service.listTags(namespaceSlug, skillSlug); + List result = service.listTags(namespaceSlug, skillSlug, null, java.util.Map.of()); // Assert assertEquals(2, result.size()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java index 29ebd961..00bf2133 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java @@ -1,6 +1,10 @@ package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +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.SkillVisibility; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,11 +20,17 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SkillRatingServiceTest { @Mock SkillRatingRepository ratingRepository; + @Mock SkillRepository skillRepository; @Mock ApplicationEventPublisher eventPublisher; @InjectMocks SkillRatingService service; + private Skill skill() { + return new Skill(1L, "skill-1", "owner-1", SkillVisibility.PUBLIC); + } + @Test void rate_creates_new_rating() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty()); when(ratingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -32,6 +42,7 @@ class SkillRatingServiceTest { @Test void rate_updates_existing_rating() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); SkillRating existing = new SkillRating(1L, "10", (short) 3); when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing)); when(ratingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -45,6 +56,7 @@ class SkillRatingServiceTest { @Test void rate_invalid_score_throws() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); assertThatThrownBy(() -> service.rate(1L, "10", (short) 0)) .isInstanceOf(DomainBadRequestException.class); assertThatThrownBy(() -> service.rate(1L, "10", (short) 6)) @@ -53,8 +65,16 @@ class SkillRatingServiceTest { @Test void getUserRating_returns_score() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); SkillRating existing = new SkillRating(1L, "10", (short) 4); when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing)); assertThat(service.getUserRating(1L, "10")).hasValue((short) 4); } + + @Test + void getUserRating_throws_when_skill_missing() { + when(skillRepository.findById(99L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.getUserRating(99L, "10")) + .isInstanceOf(DomainNotFoundException.class); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java index a9e30d1b..2c4a2197 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java @@ -1,5 +1,9 @@ package com.iflytek.skillhub.domain.social; +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.SkillVisibility; import com.iflytek.skillhub.domain.social.event.SkillStarredEvent; import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent; import org.junit.jupiter.api.Test; @@ -16,11 +20,17 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SkillStarServiceTest { @Mock SkillStarRepository starRepository; + @Mock SkillRepository skillRepository; @Mock ApplicationEventPublisher eventPublisher; @InjectMocks SkillStarService service; + private Skill skill() { + return new Skill(1L, "skill-1", "owner-1", SkillVisibility.PUBLIC); + } + @Test void star_skill_creates_record_and_publishes_event() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty()); when(starRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -32,6 +42,7 @@ class SkillStarServiceTest { @Test void star_skill_already_starred_is_idempotent() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")) .thenReturn(Optional.of(new SkillStar(1L, "10"))); @@ -43,6 +54,7 @@ class SkillStarServiceTest { @Test void unstar_skill_deletes_record_and_publishes_event() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); SkillStar existing = new SkillStar(1L, "10"); when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing)); @@ -54,6 +66,7 @@ class SkillStarServiceTest { @Test void unstar_skill_not_starred_is_noop() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty()); service.unstar(1L, "10"); @@ -64,8 +77,16 @@ class SkillStarServiceTest { @Test void isStarred_returns_true_when_exists() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")) .thenReturn(Optional.of(new SkillStar(1L, "10"))); assertThat(service.isStarred(1L, "10")).isTrue(); } + + @Test + void star_skill_throws_when_skill_missing() { + when(skillRepository.findById(99L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.star(99L, "10")) + .isInstanceOf(DomainNotFoundException.class); + } } diff --git a/server/skillhub-infra/pom.xml b/server/skillhub-infra/pom.xml index e3d346ef..567f856e 100644 --- a/server/skillhub-infra/pom.xml +++ b/server/skillhub-infra/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.2 skillhub-infra diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java new file mode 100644 index 00000000..8d6b4606 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.audit.AuditLog; +import com.iflytek.skillhub.domain.audit.AuditLogRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +@Repository +public interface AuditLogJpaRepository extends JpaRepository, JpaSpecificationExecutor, AuditLogRepository { + + @Override + default Page search(String actorUserId, String action, Pageable pageable) { + Specification specification = Specification.where(null); + if (actorUserId != null && !actorUserId.isBlank()) { + specification = specification.and((root, query, cb) -> cb.equal(root.get("actorUserId"), actorUserId)); + } + if (action != null && !action.isBlank()) { + specification = specification.and((root, query, cb) -> cb.equal(root.get("action"), action)); + } + return findAll(specification, pageable); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java new file mode 100644 index 00000000..887d2476 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -0,0 +1,64 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@Primary +public class JpaSkillRepositoryAdapter implements SkillRepository { + + private final SkillJpaRepository delegate; + private final JpaRepository jpaDelegate; + + public JpaSkillRepositoryAdapter(SkillJpaRepository delegate) { + this.delegate = delegate; + this.jpaDelegate = delegate; + } + + @Override + public Optional findById(Long id) { + return jpaDelegate.findById(id); + } + + @Override + public List findByIdIn(List ids) { + return delegate.findByIdIn(ids); + } + + @Override + public List findAll() { + return jpaDelegate.findAll(); + } + + @Override + public Optional findByNamespaceIdAndSlug(Long namespaceId, String slug) { + return delegate.findByNamespaceIdAndSlug(namespaceId, slug); + } + + @Override + public List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) { + return delegate.findByNamespaceIdAndStatus(namespaceId, status); + } + + @Override + public Skill save(Skill skill) { + return jpaDelegate.save(skill); + } + + @Override + public List findByOwnerId(String ownerId) { + return delegate.findByOwnerId(ownerId); + } + + @Override + public void incrementDownloadCount(Long skillId) { + delegate.incrementDownloadCount(skillId); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java index 570dd992..5ff37a7e 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java @@ -17,6 +17,8 @@ public interface ReviewTaskJpaRepository extends JpaRepository Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); + Page findByStatus(ReviewTaskStatus status, Pageable pageable); + Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java index c7dcc3ca..8fbb45cc 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java @@ -2,11 +2,32 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface UserAccountJpaRepository extends JpaRepository, JpaSpecificationExecutor, UserAccountRepository { + + @Override + @Query(""" + SELECT u + FROM UserAccount u + WHERE (:status IS NULL OR u.status = :status) + AND ( + :keyword IS NULL + OR lower(u.displayName) LIKE lower(concat('%', :keyword, '%')) + OR lower(coalesce(u.email, '')) LIKE lower(concat('%', :keyword, '%')) + OR lower(u.id) LIKE lower(concat('%', :keyword, '%')) + ) + """) + Page search(@Param("keyword") String keyword, + @Param("status") UserStatus status, + Pageable pageable); } diff --git a/server/skillhub-search/pom.xml b/server/skillhub-search/pom.xml index 83643cd5..e4fde4c1 100644 --- a/server/skillhub-search/pom.xml +++ b/server/skillhub-search/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.2 skillhub-search diff --git a/server/skillhub-storage/pom.xml b/server/skillhub-storage/pom.xml index 2aeb5cc6..eb765977 100644 --- a/server/skillhub-storage/pom.xml +++ b/server/skillhub-storage/pom.xml @@ -7,7 +7,7 @@ com.iflytek.skillhub skillhub-parent - 0.1.0-SNAPSHOT + 0.1.0-beta.2 skillhub-storage diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java index 26889aa0..fc03d788 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java @@ -6,7 +6,7 @@ import org.springframework.stereotype.Service; import java.io.*; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; -import java.time.Instant; +import java.time.Duration; import java.util.List; @Service @@ -58,6 +58,11 @@ public class LocalFileStorageService implements ObjectStorageService { } catch (IOException e) { throw new UncheckedIOException("Failed to get metadata: " + key, e); } } + @Override + public String generatePresignedUrl(String key, Duration expiry) { + return null; + } + private Path resolve(String key) { Path resolved = basePath.resolve(key).normalize(); if (!resolved.startsWith(basePath)) { diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java index d5bb0b24..e154facf 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.storage; import java.io.InputStream; +import java.time.Duration; import java.util.List; public interface ObjectStorageService { @@ -10,4 +11,5 @@ public interface ObjectStorageService { void deleteObjects(List keys); boolean exists(String key); ObjectMetadata getMetadata(String key); + String generatePresignedUrl(String key, Duration expiry); } diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java index 433ef50e..ebca94f7 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java @@ -11,9 +11,13 @@ import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; import java.io.InputStream; import java.net.URI; +import java.time.Duration; import java.util.List; @Service @@ -22,6 +26,7 @@ public class S3StorageService implements ObjectStorageService { private static final Logger log = LoggerFactory.getLogger(S3StorageService.class); private final S3StorageProperties properties; private S3Client s3Client; + private S3Presigner s3Presigner; public S3StorageService(S3StorageProperties properties) { this.properties = properties; } @@ -36,6 +41,14 @@ public class S3StorageService implements ObjectStorageService { builder.endpointOverride(URI.create(properties.getEndpoint())); } this.s3Client = builder.build(); + var presignerBuilder = S3Presigner.builder() + .region(Region.of(properties.getRegion())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()))); + if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) { + presignerBuilder.endpointOverride(URI.create(properties.getEndpoint())); + } + this.s3Presigner = presignerBuilder.build(); ensureBucketExists(); } @@ -74,4 +87,18 @@ public class S3StorageService implements ObjectStorageService { HeadObjectResponse resp = s3Client.headObject(HeadObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); return new ObjectMetadata(resp.contentLength(), resp.contentType(), resp.lastModified()); } + + @Override + public String generatePresignedUrl(String key, Duration expiry) { + PresignedGetObjectRequest request = s3Presigner.presignGetObject( + GetObjectPresignRequest.builder() + .signatureDuration(expiry) + .getObjectRequest(GetObjectRequest.builder() + .bucket(properties.getBucket()) + .key(key) + .build()) + .build() + ); + return request.url().toString(); + } } diff --git a/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java b/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java index d2e98611..1c29af9f 100644 --- a/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java +++ b/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java @@ -7,13 +7,24 @@ import org.junit.jupiter.api.io.TempDir; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class LocalFileStorageServiceTest { - @TempDir Path tempDir; + + @TempDir + Path tempDir; + private LocalFileStorageService storageService; @BeforeEach @@ -23,21 +34,26 @@ class LocalFileStorageServiceTest { storageService = new LocalFileStorageService(props); } - @Test void shouldPutAndGetObject() throws Exception { + @Test + void shouldPutAndGetObject() throws Exception { String key = "skills/1/1/SKILL.md"; byte[] content = "# Hello".getBytes(StandardCharsets.UTF_8); storageService.putObject(key, new ByteArrayInputStream(content), content.length, "text/markdown"); - try (InputStream result = storageService.getObject(key)) { assertArrayEquals(content, result.readAllBytes()); } + try (InputStream result = storageService.getObject(key)) { + assertArrayEquals(content, result.readAllBytes()); + } } - @Test void shouldCheckExistence() { + @Test + void shouldCheckExistence() { assertFalse(storageService.exists("test/exists.txt")); byte[] content = "data".getBytes(StandardCharsets.UTF_8); storageService.putObject("test/exists.txt", new ByteArrayInputStream(content), content.length, "text/plain"); assertTrue(storageService.exists("test/exists.txt")); } - @Test void shouldDeleteObject() { + @Test + void shouldDeleteObject() { byte[] content = "data".getBytes(StandardCharsets.UTF_8); storageService.putObject("test/delete.txt", new ByteArrayInputStream(content), content.length, "text/plain"); assertTrue(storageService.exists("test/delete.txt")); @@ -45,7 +61,8 @@ class LocalFileStorageServiceTest { assertFalse(storageService.exists("test/delete.txt")); } - @Test void shouldDeleteMultipleObjects() { + @Test + void shouldDeleteMultipleObjects() { byte[] content = "data".getBytes(StandardCharsets.UTF_8); storageService.putObject("a/1.txt", new ByteArrayInputStream(content), content.length, "text/plain"); storageService.putObject("a/2.txt", new ByteArrayInputStream(content), content.length, "text/plain"); @@ -54,7 +71,8 @@ class LocalFileStorageServiceTest { assertFalse(storageService.exists("a/2.txt")); } - @Test void shouldGetMetadata() { + @Test + void shouldGetMetadata() { byte[] content = "hello world".getBytes(StandardCharsets.UTF_8); storageService.putObject("test/meta.txt", new ByteArrayInputStream(content), content.length, "text/plain"); ObjectMetadata metadata = storageService.getMetadata("test/meta.txt"); @@ -62,19 +80,31 @@ class LocalFileStorageServiceTest { assertNotNull(metadata.lastModified()); } - @Test void shouldRejectPathTraversalKeys() { + @Test + void shouldRejectPathTraversalKeys() { byte[] content = "data".getBytes(StandardCharsets.UTF_8); IllegalArgumentException putError = assertThrows( - IllegalArgumentException.class, - () -> storageService.putObject("../escape.txt", new ByteArrayInputStream(content), content.length, "text/plain") + IllegalArgumentException.class, + () -> storageService.putObject("../escape.txt", new ByteArrayInputStream(content), content.length, "text/plain") ); assertEquals("Invalid storage key: ../escape.txt", putError.getMessage()); IllegalArgumentException getError = assertThrows( - IllegalArgumentException.class, - () -> storageService.getObject("..\\escape.txt") + IllegalArgumentException.class, + () -> storageService.getObject("..\\escape.txt") ); assertEquals("Invalid storage key: ..\\escape.txt", getError.getMessage()); } + + @Test + void generatePresignedUrlReturnsNullForLocalStorage() throws Exception { + StorageProperties properties = new StorageProperties(); + properties.getLocal().setBasePath(tempDir.toString()); + Files.createDirectories(tempDir); + + LocalFileStorageService service = new LocalFileStorageService(properties); + + assertThat(service.generatePresignedUrl("packages/demo.zip", Duration.ofMinutes(10))).isNull(); + } } diff --git a/web/LANDING_PAGE_REDESIGN.md b/web/LANDING_PAGE_REDESIGN.md new file mode 100644 index 00000000..dc63b27c --- /dev/null +++ b/web/LANDING_PAGE_REDESIGN.md @@ -0,0 +1,75 @@ +# SkillHub 落地页重新设计 + +## 设计理念 + +采用"技术编织"(Tech Weave)美学风格,通过以下元素传达 SkillHub 作为企业级技能注册中心的专业性和创新性: + +### 视觉特点 + +1. **动态粒子系统** + - Canvas 实现的粒子连接动画 + - 象征技能之间的连接和协作 + - 80个粒子节点,动态连线效果 + +2. **配色方案** + - 深蓝紫色基调(slate-950, indigo-950) + - 霓虹青色点缀(cyan-500) + - 紫罗兰色辅助(violet-500) + - 营造科技感和未来感 + +3. **字体选择** + - 标题:Syne(几何感强,现代) + - 正文:IBM Plex Sans(技术感,易读) + - 代码:JetBrains Mono + +4. **动效设计** + - 渐入动画(fade-up, fade-in) + - 悬浮卡片效果(hover:scale-105) + - 渐变光晕(gradient glow) + - 脉冲动画(pulse) + +### 内容结构 + +1. **Hero 区域** + - 大标题 + 渐变文字效果 + - 搜索栏(带光晕效果) + - CTA 按钮(探索技能 / 发布技能) + - 统计数据展示(技能包、下载量、团队) + +2. **特性展示** + - 6个核心特性卡片 + - 图标 + 标题 + 描述 + - 悬浮交互效果 + +3. **CTA 区域** + - 快速开始指引 + - 命令行示例 + - 行动按钮 + +4. **页脚** + - 版权信息 + - 导航链接 + +## 技术实现 + +- React + TypeScript +- TailwindCSS +- Canvas API(粒子动画) +- TanStack Router + +## 文件变更 + +- 新增:`web/src/pages/landing.tsx` - 新的落地页组件 +- 修改:`web/src/app/router.tsx` - 路由配置,将 landing 设为首页 +- 修改:`web/index.html` - 更新字体引用 +- 修改:`web/src/index.css` - 更新字体配置 + +## 本地预览 + +```bash +cd web +npm install --legacy-peer-deps +npm run dev +``` + +访问 `http://localhost:5173` 查看新的落地页。 diff --git a/web/PREVIEW.md b/web/PREVIEW.md new file mode 100644 index 00000000..e595ea5c --- /dev/null +++ b/web/PREVIEW.md @@ -0,0 +1,55 @@ +# 落地页预览说明 + +## 如何查看 + +1. 启动开发服务器: +```bash +cd web +npm run dev +``` + +2. 访问 `http://localhost:5173` + +## 页面特性 + +### 动态效果 +- 背景粒子动画会自动运行 +- 鼠标悬停在特性卡片上会有缩放和颜色变化 +- 按钮有渐变和阴影效果 +- 搜索栏有光晕效果 + +### 响应式设计 +- 移动端:单列布局,较小字体 +- 平板:2列特性卡片 +- 桌面:3列特性卡片,大标题 + +### 交互元素 +- "探索技能" 按钮 → 跳转到搜索页面 +- "发布技能" 按钮 → 跳转到发布页面 +- 搜索栏 → 输入后跳转到搜索结果 +- 统计数字 → 悬停有渐变效果 + +## 与原首页的区别 + +| 特性 | 原首页 | 新落地页 | +|------|--------|----------| +| 主要内容 | 技能列表(热门/最新) | 产品介绍和特性展示 | +| 视觉风格 | 简洁卡片式 | 科技感渐变 + 粒子动画 | +| 配色 | 浅色为主 | 深色主题 + 霓虹色 | +| 目标 | 快速浏览技能 | 吸引新用户了解产品 | +| 路由 | `/` | `/`(新),原首页移至 `/skills` | + +## 设计亮点 + +1. **粒子连接动画**:80个粒子节点,距离小于120px时自动连线,透明度随距离变化 +2. **渐变文字**:标题使用 cyan → blue → violet 三色渐变 +3. **光晕效果**:搜索栏和背景有模糊的光晕装饰 +4. **分层动画**:不同元素有不同的延迟时间,形成层次感 +5. **悬浮交互**:卡片悬停时会上浮并显示边框高亮 + +## 技术细节 + +- 粒子系统使用 Canvas 2D API +- 动画使用 CSS animations 和 transitions +- 响应式使用 Tailwind 的 breakpoint 系统 +- 字体通过 Google Fonts CDN 加载 diff --git a/web/index.html b/web/index.html index 3b775782..cd90385f 100644 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,9 @@ SkillHub + + +
diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 00000000..e1d6483b --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,6035 @@ +{ + "name": "skillhub-web", + "version": "0.1.0-beta.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "skillhub-web", + "version": "0.1.0-beta.2", + "dependencies": { + "@tanstack/react-query": "^5.64.0", + "@tanstack/react-router": "^1.95.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.344.0", + "openapi-fetch": "^0.13.8", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-dropzone": "^15.0.0", + "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", + "tailwind-merge": "^2.2.1", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.0", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "openapi-typescript": "^7.6.1", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.7.0", + "vite": "^6.1.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmmirror.com/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmmirror.com/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.10", + "resolved": "https://registry.npmmirror.com/@redocly/openapi-core/-/openapi-core-1.34.10.tgz", + "integrity": "sha512-XCBR/9WHJ0cpezuunHMZjuFMl4KqUo7eiFwzrQrvm7lTXt0EBd3No8UY+9OyzXpDfreGEMMtxmaLZ+ksVw378g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/history": { + "version": "1.161.4", + "resolved": "https://registry.npmmirror.com/@tanstack/history/-/history-1.161.4.tgz", + "integrity": "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.166.7", + "resolved": "https://registry.npmmirror.com/@tanstack/react-router/-/react-router-1.166.7.tgz", + "integrity": "sha512-LLcXu2nrCn2WL+w0YAbg3CRZIIO2cYVSC3y+ZYlFBxBs4hh8eoNP1EWFvRLZGCFYpqON7x6qUf1u0W7tH0cJJw==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.4", + "@tanstack/react-store": "^0.9.1", + "@tanstack/router-core": "1.166.7", + "isbot": "^5.1.22", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.2", + "resolved": "https://registry.npmmirror.com/@tanstack/react-store/-/react-store-0.9.2.tgz", + "integrity": "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.2", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.166.7", + "resolved": "https://registry.npmmirror.com/@tanstack/router-core/-/router-core-1.166.7.tgz", + "integrity": "sha512-MCc8wYIIcxmbeidM8PL2QeaAjUIHyhEDIZPW6NGfn/uwvyi+K2ucn3AGCxxcXl4JGGm0Mx9+7buYl1v3HdcFrg==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.4", + "@tanstack/store": "^0.9.1", + "cookie-es": "^2.0.0", + "seroval": "^1.4.2", + "seroval-plugins": "^1.4.2", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.2", + "resolved": "https://registry.npmmirror.com/@tanstack/store/-/store-0.9.2.tgz", + "integrity": "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmmirror.com/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001778", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", + "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmmirror.com/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/cookie-es/-/cookie-es-2.0.0.tgz", + "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmmirror.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmmirror.com/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.1", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmmirror.com/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isbot": { + "version": "5.1.36", + "resolved": "https://registry.npmmirror.com/isbot/-/isbot-5.1.36.tgz", + "integrity": "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmmirror.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.344.0", + "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.344.0.tgz", + "integrity": "sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openapi-fetch": { + "version": "0.13.8", + "resolved": "https://registry.npmmirror.com/openapi-fetch/-/openapi-fetch-0.13.8.tgz", + "integrity": "sha512-yJ4QKRyNxE44baQ9mY5+r/kAzZ8yXMemtNAOFwOzRXJscdjSxxzWSNlyBAr+o5JjkUw9Lc3W7OIoca0cY3PYnQ==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.0.15" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmmirror.com/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.0.15", + "resolved": "https://registry.npmmirror.com/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz", + "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==", + "license": "MIT" + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-dropzone": { + "version": "15.0.0", + "resolved": "https://registry.npmmirror.com/react-dropzone/-/react-dropzone-15.0.0.tgz", + "integrity": "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rehype-highlight": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/seroval/-/seroval-1.5.1.tgz", + "integrity": "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/seroval-plugins/-/seroval-plugins-1.5.1.tgz", + "integrity": "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmmirror.com/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0-beta.2", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/web/package.json b/web/package.json index 78a40f98..e29bcc22 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "skillhub-web", "private": true, - "version": "0.1.0", + "version": "0.1.0-beta.2", "type": "module", "scripts": { "install:ci": "pnpm install --frozen-lockfile", @@ -24,6 +24,8 @@ "react-dropzone": "^15.0.0", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", "tailwind-merge": "^2.2.1", "zustand": "^5.0.11" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index dfa0bdf0..a74658e0 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -41,6 +41,12 @@ importers: rehype-highlight: specifier: ^7.0.2 version: 7.0.2 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 tailwind-merge: specifier: ^2.2.1 version: 2.6.1 @@ -910,6 +916,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-plugin-react-hooks@4.6.2: resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} engines: {node: '>=10'} @@ -1061,6 +1071,9 @@ packages: hast-util-is-element@3.0.0: resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -1230,9 +1243,33 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + mdast-util-from-markdown@2.0.3: resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -1261,6 +1298,27 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -1547,12 +1605,21 @@ packages: rehype-highlight@7.0.2: resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2616,6 +2683,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): dependencies: eslint: 8.57.1 @@ -2796,6 +2865,12 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -2954,6 +3029,15 @@ snapshots: dependencies: react: 19.2.4 + markdown-table@3.0.4: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 @@ -2971,6 +3055,63 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -3064,6 +3205,64 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -3397,6 +3596,22 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -3414,6 +3629,12 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + require-from-string@2.0.2: {} resolve-from@4.0.0: {} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index df1e06c0..6bcedc04 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,6 +1,24 @@ import createClient from 'openapi-fetch' import type { paths } from './generated/schema' -import type { ApiToken, CreateTokenRequest, CreateTokenResponse, OAuthProvider, User } from './types' +import type { + ChangePasswordRequest, + ApiToken, + CreateTokenRequest, + CreateTokenResponse, + MergeConfirmRequest, + LocalLoginRequest, + LocalRegisterRequest, + MergeInitiateRequest, + MergeInitiateResponse, + MergeVerifyRequest, + ReviewTask, + PromotionTask, + AdminUser, + AuditLogItem, + SkillSummary, + OAuthProvider, + User, +} from './types' const client = createClient({ baseUrl: '' }) @@ -21,6 +39,13 @@ function withCsrf(headers?: HeadersInit): HeadersInit { } } +async function ensureCsrfHeaders(headers?: HeadersInit): Promise { + if (!getCsrfToken()) { + await client.GET('/api/v1/auth/providers') + } + return withCsrf(headers) +} + function isApiEnvelope(value: unknown): value is ApiEnvelope { return typeof value === 'object' && value !== null && 'code' in value && 'msg' in value && 'data' in value } @@ -94,7 +119,13 @@ export async function fetchText(input: RequestInfo | URL, init?: RequestInit): P export async function getCurrentUser(): Promise { try { - return await unwrap(client.GET('/api/v1/auth/me') as never) + const user = await unwrap(client.GET('/api/v1/auth/me') as never) + return { + ...user, + userId: user.userId ?? '', + displayName: user.displayName ?? '', + platformRoles: user.platformRoles ?? [], + } } catch (error) { if (error instanceof Error && error.message === 'HTTP 401') { return null @@ -107,31 +138,122 @@ export const authApi = { getMe: getCurrentUser, async getProviders(): Promise { - return unwrap(client.GET('/api/v1/auth/providers') as never) + const providers = await unwrap(client.GET('/api/v1/auth/providers') as never) + return providers + .filter((provider) => provider.id && provider.name && provider.authorizationUrl) + .map((provider) => ({ + ...provider, + id: provider.id!, + name: provider.name!, + authorizationUrl: provider.authorizationUrl!, + })) + }, + + async localLogin(request: LocalLoginRequest): Promise { + return fetchJson('/api/v1/auth/local/login', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async localRegister(request: LocalRegisterRequest): Promise { + return fetchJson('/api/v1/auth/local/register', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async changePassword(request: ChangePasswordRequest): Promise { + await fetchJson('/api/v1/auth/local/change-password', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) }, async logout(): Promise { - const { response, error } = await client.POST('/api/v1/auth/logout', { + const response = await fetch('/api/v1/auth/logout', { + method: 'POST', headers: withCsrf(), }) - if (error || (response.status !== 200 && response.status !== 204)) { + if (response.status !== 200 && response.status !== 204) { throw new Error(`HTTP ${response.status}`) } }, } +export const accountApi = { + async initiateMerge(request: MergeInitiateRequest): Promise { + return fetchJson('/api/v1/account/merge/initiate', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async verifyMerge(request: MergeVerifyRequest): Promise { + await fetchJson('/api/v1/account/merge/verify', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async confirmMerge(request: MergeConfirmRequest): Promise { + await fetchJson('/api/v1/account/merge/confirm', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, +} + export const tokenApi = { async getTokens(): Promise { - return unwrap(client.GET('/api/v1/tokens') as never) + const tokens = await unwrap(client.GET('/api/v1/tokens') as never) + return tokens + .filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt) + .map((token) => ({ + ...token, + id: token.id!, + name: token.name!, + tokenPrefix: token.tokenPrefix!, + createdAt: token.createdAt!, + })) }, async createToken(request: CreateTokenRequest): Promise { - return unwrap(client.POST('/api/v1/tokens', { + const token = await unwrap(client.POST('/api/v1/tokens', { headers: withCsrf({ 'Content-Type': 'application/json', }), body: request, }) as never) + if (!token.token || token.id === undefined || !token.name || !token.tokenPrefix || !token.createdAt) { + throw new Error('Invalid token creation response') + } + return { + ...token, + token: token.token, + id: token.id, + name: token.name, + tokenPrefix: token.tokenPrefix, + createdAt: token.createdAt, + } }, async deleteToken(tokenId: number): Promise { @@ -148,3 +270,168 @@ export const tokenApi = { } }, } + +export const reviewApi = { + async list(params: { status: string; namespaceId?: number; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + searchParams.set('status', params.status) + if (params.namespaceId !== undefined) { + searchParams.set('namespaceId', String(params.namespaceId)) + } + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: ReviewTask[]; total: number; page: number; size: number }>( + `/api/v1/reviews?${searchParams.toString()}`, + ) + }, + + async get(id: number): Promise { + return fetchJson(`/api/v1/reviews/${id}`) + }, + + async approve(id: number, comment?: string): Promise { + await fetchJson(`/api/v1/reviews/${id}/approve`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, + + async reject(id: number, comment: string): Promise { + await fetchJson(`/api/v1/reviews/${id}/reject`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, +} + +export const promotionApi = { + async list(params: { status?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + searchParams.set('status', params.status ?? 'PENDING') + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: PromotionTask[]; total: number; page: number; size: number }>( + `/api/v1/promotions?${searchParams.toString()}`, + ) + }, + + async get(id: number): Promise { + return fetchJson(`/api/v1/promotions/${id}`) + }, + + async approve(id: number, comment?: string): Promise { + await fetchJson(`/api/v1/promotions/${id}/approve`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, + + async reject(id: number, comment?: string): Promise { + await fetchJson(`/api/v1/promotions/${id}/reject`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, +} + +export const meApi = { + async getStars(): Promise { + return fetchJson('/api/v1/me/stars') + }, +} + +export const adminApi = { + async getUsers(params: { search?: string; status?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + if (params.search) searchParams.set('search', params.search) + if (params.status) searchParams.set('status', params.status) + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: AdminUser[]; total: number; page: number; size: number }>( + `/api/v1/admin/users?${searchParams.toString()}`, + ) + }, + + async updateUserRole(userId: string, role: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/role`, { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ role }), + }) + }, + + async updateUserStatus(userId: string, status: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/status`, { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ status }), + }) + }, + + async approveUser(userId: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/approve`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + + async disableUser(userId: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/disable`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + + async enableUser(userId: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/enable`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + + async getAuditLogs(params: { action?: string; userId?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + if (params.action) searchParams.set('action', params.action) + if (params.userId) searchParams.set('userId', params.userId) + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: AuditLogItem[]; total: number; page: number; size: number }>( + `/api/v1/admin/audit-logs?${searchParams.toString()}`, + ) + }, + + async hideSkill(skillId: number, reason?: string): Promise { + await fetchJson(`/api/v1/admin/skills/${skillId}/hide`, { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ reason }), + }) + }, + + async unhideSkill(skillId: number): Promise { + await fetchJson(`/api/v1/admin/skills/${skillId}/unhide`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + + async yankVersion(versionId: number, reason?: string): Promise { + await fetchJson(`/api/v1/admin/skills/versions/${versionId}/yank`, { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ reason }), + }) + }, +} diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 03c59c34..8107fca8 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1,125 +1,3840 @@ -export interface paths { - '/api/v1/auth/me': { - get: { - responses: { - 200: { - content: { - 'application/json': components['schemas']['User'] - } - } - 401: { - content?: never - } - } - } - } - '/api/v1/auth/providers': { - get: { - responses: { - 200: { - content: { - 'application/json': components['schemas']['ApiResponse_OAuthProviderList'] - } - } - } - } - } - '/api/v1/auth/logout': { - post: { - responses: { - 200: { - content?: never - } - 204: { - content?: never - } - } - } - } - '/api/v1/tokens': { - get: { - responses: { - 200: { - content: { - 'application/json': components['schemas']['ApiResponse_ApiTokenList'] - } - } - } - } - post: { - requestBody: { - content: { - 'application/json': components['schemas']['CreateTokenRequest'] - } - } - responses: { - 200: { - content: { - 'application/json': components['schemas']['ApiResponse_CreateTokenResponse'] - } - } - } - } - } - '/api/v1/tokens/{id}': { - delete: { - parameters: { - path: { - id: number - } - } - responses: { - 204: { - content?: never - } - } - } - } -} +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ -export interface components { - schemas: { - User: { - userId: string - displayName: string - email: string - avatarUrl: string - oauthProvider: string - platformRoles: string[] - } - OAuthProvider: { - id: string - name: string - authorizationUrl: string - } - ApiToken: { - id: number - name: string - tokenPrefix: string - createdAt: string - expiresAt: string - lastUsedAt: string - } - CreateTokenRequest: { - name: string - scopes?: string[] - } - CreateTokenResponse: { - token: string - id: number - name: string - tokenPrefix: string - createdAt: string - expiresAt: string - } - ApiResponse_OAuthProviderList: { - data: components['schemas']['OAuthProvider'][] - } - ApiResponse_ApiTokenList: { - data: components['schemas']['ApiToken'][] - } - ApiResponse_CreateTokenResponse: { - data: components['schemas']['CreateTokenResponse'] - } - } +export interface paths { + "/api/v1/skills/{skillId}/star": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["checkStarred"]; + put: operations["starSkill"]; + post?: never; + delete: operations["unstarSkill"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{skillId}/rating": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getUserRating"]; + put: operations["rateSkill"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags/{tagName}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["createOrMoveTag"]; + post?: never; + delete: operations["deleteTag"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getNamespace"]; + put: operations["updateNamespace"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/members/{userId}/role": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateMemberRole"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateUserStatus"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/role": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateUserRole"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list"]; + put?: never; + post: operations["create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["publish"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listReviews"]; + put?: never; + post: operations["submitReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/{id}/withdraw": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["withdrawReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/{id}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["rejectReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/{id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["approveReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPromotions"]; + put?: never; + post: operations["submitPromotion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions/{id}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["rejectPromotion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions/{id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["approvePromotion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listNamespaces"]; + put?: never; + post: operations["createNamespace"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMembers"]; + put?: never; + post: operations["addMember"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/device/authorize": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["authorizeDevice"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/cli/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["publish_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/cli/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["check"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/cli/auth/device/token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["pollToken"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/cli/auth/device/code": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["requestDeviceCode"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/local/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/local/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/local/change-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["changePassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["enableUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["disableUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users/{userId}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["approveUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/skills/{skillId}/unhide": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["unhideSkill"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/skills/{skillId}/hide": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["hideSkill"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/skills/versions/{versionId}/yank": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["yankVersion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["verify"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/initiate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["initiate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/confirm": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["confirm"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/compat/v1/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["publish_2"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["search"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getSkillDetail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listVersions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions/{version}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getVersionDetail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions/{version}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listFiles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions/{version}/file": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getFileContent"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions/{version}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadVersion"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listTags"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags/{tagName}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listFilesByTag"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags/{tagName}/file": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getFileContentByTag"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/tags/{tagName}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadByTag"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["resolveVersion"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadLatest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getReviewDetail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/pending": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPendingReviews"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/my-submissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMySubmissions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPromotionDetail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotions/pending": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPendingPromotions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/me/stars": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyStars"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/me/skills": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMySkills"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/cli/whoami": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["whoami"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/cli/resolve/{namespace}/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["resolve"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["providers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["me"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listUsers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/audit-logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listAuditLogs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/compat/v1/whoami": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["whoami_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/compat/v1/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["search_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/compat/v1/resolve/{canonicalSlug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["resolve_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/compat/v1/download/{canonicalSlug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["download"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/.well-known/clawhub.json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["clawhubConfig"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tokens/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["revoke"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/members/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["removeMember"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + ApiResponseVoid: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: Record; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillRatingRequest: { + /** Format: int32 */ + score: number; + }; + TagRequest: { + tagName: string; + targetVersion: string; + }; + ApiResponseTagResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["TagResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + TagResponse: { + /** Format: int64 */ + id?: number; + tagName?: string; + /** Format: int64 */ + versionId?: number; + /** Format: date-time */ + createdAt?: string; + }; + NamespaceRequest: { + slug: string; + displayName: string; + description?: string; + }; + ApiResponseNamespaceResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["NamespaceResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + NamespaceResponse: { + /** Format: int64 */ + id?: number; + slug?: string; + displayName?: string; + /** @enum {string} */ + status?: "ACTIVE" | "FROZEN" | "ARCHIVED"; + description?: string; + /** @enum {string} */ + type?: "GLOBAL" | "TEAM"; + avatarUrl?: string; + createdBy?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + UpdateMemberRoleRequest: { + /** @enum {string} */ + role: "OWNER" | "ADMIN" | "MEMBER"; + }; + ApiResponseMemberResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MemberResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MemberResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + namespaceId?: number; + userId?: string; + /** @enum {string} */ + role?: "OWNER" | "ADMIN" | "MEMBER"; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + AdminUserStatusUpdateRequest: { + status: string; + }; + AdminUserMutationResponse: { + userId?: string; + role?: string; + status?: string; + }; + ApiResponseAdminUserMutationResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AdminUserMutationResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AdminUserRoleUpdateRequest: { + role: string; + }; + TokenCreateRequest: { + name: string; + scopes?: string[]; + }; + ApiResponseTokenCreateResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["TokenCreateResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + TokenCreateResponse: { + token?: string; + /** Format: int64 */ + id?: number; + name?: string; + tokenPrefix?: string; + createdAt?: string; + expiresAt?: string; + }; + ApiResponsePublishResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PublishResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PublishResponse: { + /** Format: int64 */ + skillId?: number; + namespace?: string; + slug?: string; + version?: string; + status?: string; + /** Format: int32 */ + fileCount?: number; + /** Format: int64 */ + totalSize?: number; + }; + ReviewTaskRequest: { + /** Format: int64 */ + skillVersionId?: number; + }; + ApiResponseReviewTaskResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ReviewTaskResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ReviewTaskResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + skillVersionId?: number; + namespace?: string; + skillSlug?: string; + version?: string; + status?: string; + submittedBy?: string; + submittedByName?: string; + reviewedBy?: string; + reviewedByName?: string; + reviewComment?: string; + /** Format: date-time */ + submittedAt?: string; + /** Format: date-time */ + reviewedAt?: string; + }; + ReviewActionRequest: { + comment?: string; + }; + PromotionRequestDto: { + /** Format: int64 */ + sourceSkillId?: number; + /** Format: int64 */ + sourceVersionId?: number; + /** Format: int64 */ + targetNamespaceId?: number; + }; + ApiResponsePromotionResponseDto: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PromotionResponseDto"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PromotionResponseDto: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + sourceSkillId?: number; + sourceNamespace?: string; + sourceSkillSlug?: string; + sourceVersion?: string; + targetNamespace?: string; + /** Format: int64 */ + targetSkillId?: number; + status?: string; + submittedBy?: string; + submittedByName?: string; + reviewedBy?: string; + reviewedByName?: string; + reviewComment?: string; + /** Format: date-time */ + submittedAt?: string; + /** Format: date-time */ + reviewedAt?: string; + }; + PromotionActionRequest: { + comment?: string; + }; + MemberRequest: { + userId: string; + /** @enum {string} */ + role: "OWNER" | "ADMIN" | "MEMBER"; + }; + AuthorizeRequest: { + userCode?: string; + }; + ApiResponseMessageResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MessageResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MessageResponse: { + message?: string; + }; + ApiResponseSkillCheckResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillCheckResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillCheckResponse: { + valid?: boolean; + errors?: string[]; + /** Format: int32 */ + fileCount?: number; + /** Format: int64 */ + totalSize?: number; + }; + TokenRequest: { + deviceCode?: string; + }; + ApiResponseDeviceTokenResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DeviceTokenResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + DeviceTokenResponse: { + accessToken?: string; + tokenType?: string; + error?: string; + }; + ApiResponseDeviceCodeResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DeviceCodeResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + DeviceCodeResponse: { + deviceCode?: string; + userCode?: string; + verificationUri?: string; + /** Format: int32 */ + expiresIn?: number; + /** Format: int32 */ + interval?: number; + }; + LocalRegisterRequest: { + username: string; + password: string; + email?: string; + }; + ApiResponseAuthMeResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AuthMeResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AuthMeResponse: { + userId?: string; + displayName?: string; + email?: string; + avatarUrl?: string; + oauthProvider?: string; + platformRoles?: string[]; + }; + LocalLoginRequest: { + username: string; + password: string; + }; + ChangePasswordRequest: { + currentPassword: string; + newPassword: string; + }; + AdminSkillMutationResponse: { + /** Format: int64 */ + skillId?: number; + /** Format: int64 */ + versionId?: number; + action?: string; + status?: string; + }; + ApiResponseAdminSkillMutationResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AdminSkillMutationResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AdminSkillActionRequest: { + reason?: string; + }; + MergeVerifyRequest: { + /** Format: int64 */ + mergeRequestId: number; + verificationToken: string; + }; + MergeInitiateRequest: { + secondaryIdentifier: string; + }; + ApiResponseMergeInitiateResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MergeInitiateResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MergeInitiateResponse: { + /** Format: int64 */ + mergeRequestId?: number; + secondaryUserId?: string; + verificationToken?: string; + expiresAt?: string; + }; + ConfirmMergeRequest: { + /** Format: int64 */ + mergeRequestId: number; + }; + ClawHubPublishResponse: { + canonicalSlug?: string; + version?: string; + status?: string; + }; + ApiResponseListTokenSummaryResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["TokenSummaryResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + TokenSummaryResponse: { + /** Format: int64 */ + id?: number; + name?: string; + tokenPrefix?: string; + createdAt?: string; + expiresAt?: string; + lastUsedAt?: string; + }; + ApiResponseSearchResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SearchResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SearchResponse: { + items?: components["schemas"]["SkillSummaryResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + SkillSummaryResponse: { + /** Format: int64 */ + id?: number; + slug?: string; + displayName?: string; + summary?: string; + /** Format: int64 */ + downloadCount?: number; + /** Format: int32 */ + starCount?: number; + ratingAvg?: number; + /** Format: int32 */ + ratingCount?: number; + latestVersion?: string; + namespace?: string; + /** Format: date-time */ + updatedAt?: string; + }; + ApiResponseBoolean: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: boolean; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponseSkillRatingStatusResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillRatingStatusResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillRatingStatusResponse: { + /** Format: int32 */ + score?: number; + rated?: boolean; + }; + ApiResponseSkillDetailResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillDetailResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillDetailResponse: { + /** Format: int64 */ + id?: number; + slug?: string; + displayName?: string; + summary?: string; + visibility?: string; + status?: string; + /** Format: int64 */ + downloadCount?: number; + /** Format: int32 */ + starCount?: number; + ratingAvg?: number; + /** Format: int32 */ + ratingCount?: number; + hidden?: boolean; + latestVersion?: string; + namespace?: string; + }; + ApiResponsePageResponseSkillVersionResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseSkillVersionResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseSkillVersionResponse: { + items?: components["schemas"]["SkillVersionResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + SkillVersionResponse: { + /** Format: int64 */ + id?: number; + version?: string; + status?: string; + changelog?: string; + /** Format: int32 */ + fileCount?: number; + /** Format: int64 */ + totalSize?: number; + /** Format: date-time */ + publishedAt?: string; + }; + ApiResponseSkillVersionDetailResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillVersionDetailResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillVersionDetailResponse: { + /** Format: int64 */ + id?: number; + version?: string; + status?: string; + changelog?: string; + /** Format: int32 */ + fileCount?: number; + /** Format: int64 */ + totalSize?: number; + /** Format: date-time */ + publishedAt?: string; + parsedMetadataJson?: string; + manifestJson?: string; + }; + ApiResponseListSkillFileResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillFileResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillFileResponse: { + /** Format: int64 */ + id?: number; + filePath?: string; + /** Format: int64 */ + fileSize?: number; + contentType?: string; + sha256?: string; + }; + ApiResponseListTagResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["TagResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponseResolveVersionResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ResolveVersionResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ResolveVersionResponse: { + /** Format: int64 */ + skillId?: number; + namespace?: string; + slug?: string; + version?: string; + /** Format: int64 */ + versionId?: number; + fingerprint?: string; + matched?: boolean; + downloadUrl?: string; + }; + ApiResponsePageResponseReviewTaskResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseReviewTaskResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseReviewTaskResponse: { + items?: components["schemas"]["ReviewTaskResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponsePageResponsePromotionResponseDto: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponsePromotionResponseDto"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponsePromotionResponseDto: { + items?: components["schemas"]["PromotionResponseDto"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + Pageable: { + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + sort?: string[]; + }; + ApiResponsePageResponseNamespaceResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseNamespaceResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseNamespaceResponse: { + items?: components["schemas"]["NamespaceResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponsePageResponseMemberResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseMemberResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseMemberResponse: { + items?: components["schemas"]["MemberResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponseListSkillSummaryResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillSummaryResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponseCliWhoamiResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["CliWhoamiResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + CliWhoamiResponse: { + userId?: string; + displayName?: string; + email?: string; + avatarUrl?: string; + authType?: string; + platformRoles?: string[]; + }; + ApiResponseListAuthProviderResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AuthProviderResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AuthProviderResponse: { + id?: string; + name?: string; + authorizationUrl?: string; + }; + AdminUserSummaryResponse: { + userId?: string; + username?: string; + email?: string; + platformRoles?: string[]; + status?: string; + /** Format: date-time */ + createdAt?: string; + }; + ApiResponsePageResponseAdminUserSummaryResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseAdminUserSummaryResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseAdminUserSummaryResponse: { + items?: components["schemas"]["AdminUserSummaryResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponsePageResponseAuditLogItemResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseAuditLogItemResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AuditLogItemResponse: { + id?: string; + userId?: string; + action?: string; + resourceType?: string; + resourceId?: string; + /** Format: date-time */ + timestamp?: string; + ipAddress?: string; + }; + PageResponseAuditLogItemResponse: { + items?: components["schemas"]["AuditLogItemResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ClawHubWhoamiResponse: { + userId?: string; + displayName?: string; + email?: string; + }; + ClawHubSearchResponse: { + items?: components["schemas"]["ClawHubSkillItem"][]; + }; + ClawHubSkillItem: { + canonicalSlug?: string; + description?: string; + latestVersion?: string; + /** Format: int32 */ + starCount?: number; + }; + ClawHubResolveResponse: { + canonicalSlug?: string; + version?: string; + downloadUrl?: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + checkStarred: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseBoolean"]; + }; + }; + }; + }; + starSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + unstarSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + getUserRating: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillRatingStatusResponse"]; + }; + }; + }; + }; + rateSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillRatingRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + createOrMoveTag: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TagRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseTagResponse"]; + }; + }; + }; + }; + deleteTag: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + getNamespace: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseNamespaceResponse"]; + }; + }; + }; + }; + updateNamespace: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseNamespaceResponse"]; + }; + }; + }; + }; + updateMemberRole: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateMemberRoleRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMemberResponse"]; + }; + }; + }; + }; + updateUserStatus: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserStatusUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + updateUserRole: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserRoleUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListTokenSummaryResponse"]; + }; + }; + }; + }; + create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TokenCreateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseTokenCreateResponse"]; + }; + }; + }; + }; + publish: { + parameters: { + query: { + visibility: string; + }; + header?: never; + path: { + namespace: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePublishResponse"]; + }; + }; + }; + }; + listReviews: { + parameters: { + query: { + status: string; + namespaceId?: number; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + }; + }; + }; + }; + submitReview: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReviewTaskRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + }; + }; + }; + }; + withdrawReview: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + rejectReview: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReviewActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + }; + }; + }; + }; + approveReview: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReviewActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + }; + }; + }; + }; + listPromotions: { + parameters: { + query?: { + status?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponsePromotionResponseDto"]; + }; + }; + }; + }; + submitPromotion: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PromotionRequestDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + }; + }; + }; + }; + rejectPromotion: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PromotionActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + }; + }; + }; + }; + approvePromotion: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PromotionActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + }; + }; + }; + }; + listNamespaces: { + parameters: { + query: { + pageable: components["schemas"]["Pageable"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseNamespaceResponse"]; + }; + }; + }; + }; + createNamespace: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseNamespaceResponse"]; + }; + }; + }; + }; + listMembers: { + parameters: { + query: { + pageable: components["schemas"]["Pageable"]; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseMemberResponse"]; + }; + }; + }; + }; + addMember: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MemberRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMemberResponse"]; + }; + }; + }; + }; + authorizeDevice: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthorizeRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + publish_1: { + parameters: { + query: { + namespace: string; + visibility: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePublishResponse"]; + }; + }; + }; + }; + check: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillCheckResponse"]; + }; + }; + }; + }; + pollToken: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseDeviceTokenResponse"]; + }; + }; + }; + }; + requestDeviceCode: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseDeviceCodeResponse"]; + }; + }; + }; + }; + register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LocalRegisterRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + }; + }; + }; + }; + login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LocalLoginRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + }; + }; + }; + }; + changePassword: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChangePasswordRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + enableUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + disableUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + approveUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + unhideSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + }; + }; + }; + }; + hideSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AdminSkillActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + }; + }; + }; + }; + yankVersion: { + parameters: { + query?: never; + header?: never; + path: { + versionId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AdminSkillActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + }; + }; + }; + }; + verify: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MergeVerifyRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + initiate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MergeInitiateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMergeInitiateResponse"]; + }; + }; + }; + }; + confirm: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfirmMergeRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + publish_2: { + parameters: { + query: { + namespace: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ClawHubPublishResponse"]; + }; + }; + }; + }; + search: { + parameters: { + query?: { + q?: string; + namespace?: string; + sort?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSearchResponse"]; + }; + }; + }; + }; + getSkillDetail: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillDetailResponse"]; + }; + }; + }; + }; + listVersions: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseSkillVersionResponse"]; + }; + }; + }; + }; + getVersionDetail: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillVersionDetailResponse"]; + }; + }; + }; + }; + listFiles: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillFileResponse"]; + }; + }; + }; + }; + getFileContent: { + parameters: { + query: { + path: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + downloadVersion: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + listTags: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListTagResponse"]; + }; + }; + }; + }; + listFilesByTag: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillFileResponse"]; + }; + }; + }; + }; + getFileContentByTag: { + parameters: { + query: { + path: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + downloadByTag: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + tagName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + resolveVersion: { + parameters: { + query?: { + version?: string; + tag?: string; + hash?: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseResolveVersionResponse"]; + }; + }; + }; + }; + downloadLatest: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + getReviewDetail: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + }; + }; + }; + }; + listPendingReviews: { + parameters: { + query: { + namespaceId: number; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + }; + }; + }; + }; + listMySubmissions: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + }; + }; + }; + }; + getPromotionDetail: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + }; + }; + }; + }; + listPendingPromotions: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponsePromotionResponseDto"]; + }; + }; + }; + }; + listMyStars: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSummaryResponse"]; + }; + }; + }; + }; + listMySkills: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSummaryResponse"]; + }; + }; + }; + }; + health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + whoami: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCliWhoamiResponse"]; + }; + }; + }; + }; + resolve: { + parameters: { + query?: { + version?: string; + tag?: string; + hash?: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseResolveVersionResponse"]; + }; + }; + }; + }; + providers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListAuthProviderResponse"]; + }; + }; + }; + }; + me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + }; + }; + }; + }; + listUsers: { + parameters: { + query?: { + search?: string; + status?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseAdminUserSummaryResponse"]; + }; + }; + }; + }; + listAuditLogs: { + parameters: { + query?: { + page?: number; + size?: number; + userId?: string; + action?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseAuditLogItemResponse"]; + }; + }; + }; + }; + whoami_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ClawHubWhoamiResponse"]; + }; + }; + }; + }; + search_1: { + parameters: { + query: { + q: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ClawHubSearchResponse"]; + }; + }; + }; + }; + resolve_1: { + parameters: { + query?: { + version?: string; + }; + header?: never; + path: { + canonicalSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ClawHubResolveResponse"]; + }; + }; + }; + }; + download: { + parameters: { + query?: { + version?: string; + }; + header?: never; + path: { + canonicalSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + clawhubConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": { + [key: string]: string; + }; + }; + }; + }; + }; + revoke: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + removeMember: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b3dcdc65..3cd532b3 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1,10 +1,76 @@ import type { components } from './generated/schema' -export type User = components['schemas']['User'] -export type OAuthProvider = components['schemas']['OAuthProvider'] -export type ApiToken = components['schemas']['ApiToken'] -export type CreateTokenRequest = components['schemas']['CreateTokenRequest'] -export type CreateTokenResponse = components['schemas']['CreateTokenResponse'] +export type User = Omit & { + userId: string + displayName: string + email?: string + avatarUrl?: string + oauthProvider?: string + platformRoles: string[] +} + +export type OAuthProvider = Omit & { + id: string + name: string + authorizationUrl: string +} + +export type ApiToken = Omit & { + id: number + name: string + tokenPrefix: string + createdAt: string + expiresAt?: string + lastUsedAt?: string +} + +export type CreateTokenRequest = Omit & { + name: string + scopes?: string[] +} + +export type CreateTokenResponse = Omit & { + token: string + id: number + name: string + tokenPrefix: string + createdAt: string + expiresAt?: string +} + +export interface LocalLoginRequest { + username: string + password: string +} + +export interface LocalRegisterRequest extends LocalLoginRequest { + email?: string +} + +export interface ChangePasswordRequest { + currentPassword: string + newPassword: string +} + +export interface MergeInitiateRequest { + secondaryIdentifier: string +} + +export interface MergeInitiateResponse { + mergeRequestId: number + secondaryUserId: string + verificationToken: string + expiresAt: string +} + +export interface MergeVerifyRequest { + mergeRequestId: number + verificationToken: string +} + +export interface MergeConfirmRequest { + mergeRequestId: number +} // Namespace types export interface Namespace { @@ -50,6 +116,9 @@ export interface SkillDetail { status: string downloadCount: number starCount: number + ratingAvg?: number + ratingCount: number + hidden: boolean latestVersion?: string namespace: string } @@ -105,3 +174,56 @@ export interface PublishResult { fileCount: number totalSize: number } + +export interface ReviewTask { + id: number + skillVersionId: number + namespace: string + skillSlug: string + version: string + status: 'PENDING' | 'APPROVED' | 'REJECTED' + submittedBy: string + submittedByName?: string + reviewedBy?: string + reviewedByName?: string + reviewComment?: string + submittedAt: string + reviewedAt?: string +} + +export interface PromotionTask { + id: number + sourceSkillId: number + sourceNamespace: string + sourceSkillSlug: string + sourceVersion: string + targetNamespace: string + targetSkillId?: number + status: 'PENDING' | 'APPROVED' | 'REJECTED' + submittedBy: string + submittedByName?: string + reviewedBy?: string + reviewedByName?: string + reviewComment?: string + submittedAt: string + reviewedAt?: string +} + +export interface AdminUser { + userId: string + username: string + email?: string + platformRoles: string[] + status: string + createdAt: string +} + +export interface AuditLogItem { + id: string + userId?: string + action: string + resourceType?: string + resourceId?: string + timestamp: string + ipAddress?: string +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 5685be3f..e541adcf 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,35 +1,79 @@ -import { Outlet, Link } from '@tanstack/react-router' +import { Suspense } from 'react' +import { Outlet, Link, useRouterState } from '@tanstack/react-router' import { useAuth } from '@/features/auth/use-auth' +import { LandingPage } from '@/pages/landing' export function Layout() { const { user, isLoading } = useAuth() + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const isLanding = pathname === '/' + + if (isLanding) { + return + } return ( -
-
-
- - SkillHub +
+ {/* Glow orbs */} +
+
+ + {/* Glass header */} +
+
+ +
+ S +
+ + SkillHub + -
-
- + +
+ +
+
+
+
+ } + > + +
+ + {/* Footer */} +
+
+
+
+
+
+ S +
+ SkillHub +
+

+ 现代化的技能注册中心,为开发者提供高效的技能管理和分发平台。 +

+
+ +
+

快速链接

+
    +
  • + + 首页 + +
  • +
  • + + 搜索技能 + +
  • +
  • + + Dashboard + +
  • +
+
+ +
+

资源

+ +
+
+ +
+

+ © 2024 SkillHub. All rights reserved. +

+ +
+
+
) } diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index ff0e6f94..6af17ae0 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -3,8 +3,14 @@ import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/ import { Layout } from './layout' import { getCurrentUser } from '@/api/client' -function createLazyRouteComponent(load: () => Promise<{ default: ComponentType }>) { - const LazyComponent = lazy(load) +function createLazyRouteComponent>( + importer: () => Promise, + exportName: keyof TModule, +) { + const LazyComponent = lazy(async () => { + const module = await importer() + return { default: module[exportName] as ComponentType } + }) return function LazyRouteComponent(props: Record) { return ( @@ -21,71 +27,96 @@ function createLazyRouteComponent(load: () => Promise<{ default: ComponentType - import('@/pages/home').then((module) => ({ default: module.HomePage })), +const HomePage = createLazyRouteComponent(() => import('@/pages/home'), 'HomePage') +const LoginPage = createLazyRouteComponent(() => import('@/pages/login'), 'LoginPage') +const RegisterPage = createLazyRouteComponent(() => import('@/pages/register'), 'RegisterPage') +const SearchPage = createLazyRouteComponent(() => import('@/pages/search'), 'SearchPage') +const NamespacePage = createLazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage') +const SkillDetailPage = createLazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage') +const DashboardPage = createLazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage') +const MySkillsPage = createLazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage') +const PublishPage = createLazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage') +const MyNamespacesPage = createLazyRouteComponent( + () => import('@/pages/dashboard/my-namespaces'), + 'MyNamespacesPage', ) -const LoginPage = createLazyRouteComponent(() => - import('@/pages/login').then((module) => ({ default: module.LoginPage })), +const NamespaceMembersPage = createLazyRouteComponent( + () => import('@/pages/dashboard/namespace-members'), + 'NamespaceMembersPage', ) -const DashboardPage = createLazyRouteComponent(() => - import('@/pages/dashboard').then((module) => ({ default: module.DashboardPage })), +const NamespaceReviewsPage = createLazyRouteComponent( + () => import('@/pages/dashboard/namespace-reviews'), + 'NamespaceReviewsPage', ) -const SearchPage = createLazyRouteComponent(() => - import('@/pages/search').then((module) => ({ default: module.SearchPage })), +const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage') +const ReviewDetailPage = createLazyRouteComponent( + () => import('@/pages/dashboard/review-detail'), + 'ReviewDetailPage', ) -const NamespacePage = createLazyRouteComponent(() => - import('@/pages/namespace').then((module) => ({ default: module.NamespacePage })), +const PromotionsPage = createLazyRouteComponent( + () => import('@/pages/dashboard/promotions'), + 'PromotionsPage', ) -const SkillDetailPage = createLazyRouteComponent(() => - import('@/pages/skill-detail').then((module) => ({ default: module.SkillDetailPage })), +const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage') +const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage') +const DeviceAuthPage = createLazyRouteComponent(() => import('@/pages/device'), 'DeviceAuthPage') +const SecuritySettingsPage = createLazyRouteComponent( + () => import('@/pages/settings/security'), + 'SecuritySettingsPage', ) -const PublishPage = createLazyRouteComponent(() => - import('@/pages/dashboard/publish').then((module) => ({ default: module.PublishPage })), -) -const MySkillsPage = createLazyRouteComponent(() => - import('@/pages/dashboard/my-skills').then((module) => ({ default: module.MySkillsPage })), -) -const MyNamespacesPage = createLazyRouteComponent(() => - import('@/pages/dashboard/my-namespaces').then((module) => ({ default: module.MyNamespacesPage })), -) -const NamespaceMembersPage = createLazyRouteComponent(() => - import('@/pages/dashboard/namespace-members').then((module) => ({ default: module.NamespaceMembersPage })), -) -const ReviewsPage = createLazyRouteComponent(() => - import('@/pages/dashboard/reviews').then((module) => ({ default: module.ReviewsPage })), -) -const ReviewDetailPage = createLazyRouteComponent(() => - import('@/pages/dashboard/review-detail').then((module) => ({ default: module.ReviewDetailPage })), -) -const DeviceAuthPage = createLazyRouteComponent(() => - import('@/pages/device').then((module) => ({ default: module.DeviceAuthPage })), -) -const AdminUsersPage = createLazyRouteComponent(() => - import('@/pages/admin/users').then((module) => ({ default: module.AdminUsersPage })), -) -const AuditLogPage = createLazyRouteComponent(() => - import('@/pages/admin/audit-log').then((module) => ({ default: module.AuditLogPage })), +const AccountSettingsPage = createLazyRouteComponent( + () => import('@/pages/settings/accounts'), + 'AccountSettingsPage', ) +const AdminUsersPage = createLazyRouteComponent(() => import('@/pages/admin/users'), 'AdminUsersPage') +const AuditLogPage = createLazyRouteComponent(() => import('@/pages/admin/audit-log'), 'AuditLogPage') const rootRoute = createRootRoute({ component: Layout, }) -const homeRoute = createRoute({ +function buildReturnTo(location: { pathname: string; searchStr?: string; hash?: string }) { + return `${location.pathname}${location.searchStr ?? ''}${location.hash ?? ''}` +} + +async function requireAuth({ location }: { location: { pathname: string; searchStr?: string; hash?: string } }) { + const user = await getCurrentUser() + if (!user) { + throw redirect({ + to: '/login', + search: { returnTo: buildReturnTo(location) }, + }) + } + return { user } +} + +const skillsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/', + path: 'skills', component: HomePage, }) const loginRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/login', + path: 'login', + validateSearch: (search: Record) => ({ + returnTo: typeof search.returnTo === 'string' ? search.returnTo : '', + }), component: LoginPage, }) +const registerRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'register', + validateSearch: (search: Record) => ({ + returnTo: typeof search.returnTo === 'string' ? search.returnTo : '', + }), + component: RegisterPage, +}) + const searchRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/search', + path: 'search', component: SearchPage, validateSearch: (search: Record) => { return { @@ -98,121 +129,124 @@ const searchRoute = createRoute({ const namespaceRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/@$namespace', + path: '@$namespace', component: NamespacePage, }) const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/@$namespace/$slug', + path: '@$namespace/$slug', component: SkillDetailPage, }) const dashboardRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard', + beforeLoad: requireAuth, component: DashboardPage, }) const dashboardSkillsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/skills', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/skills', + beforeLoad: requireAuth, component: MySkillsPage, }) const dashboardPublishRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/publish', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/publish', + beforeLoad: requireAuth, component: PublishPage, }) const dashboardNamespacesRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/namespaces', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/namespaces', + beforeLoad: requireAuth, component: MyNamespacesPage, }) const dashboardNamespaceMembersRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/namespaces/$slug/members', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/namespaces/$slug/members', + beforeLoad: requireAuth, component: NamespaceMembersPage, }) +const dashboardNamespaceReviewsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/namespaces/$slug/reviews', + beforeLoad: requireAuth, + component: NamespaceReviewsPage, +}) + const dashboardReviewsRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/reviews', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } - return { user } - }, + path: 'dashboard/reviews', + beforeLoad: requireAuth, component: ReviewsPage, }) const dashboardReviewDetailRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/dashboard/reviews/$id', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) + path: 'dashboard/reviews/$id', + beforeLoad: requireAuth, + component: ReviewDetailPage, +}) + +const dashboardPromotionsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/promotions', + beforeLoad: async (ctx) => { + const { user } = await requireAuth(ctx) + if (!user.platformRoles?.includes('SKILL_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) { + throw redirect({ to: '/dashboard' }) } return { user } }, - component: ReviewDetailPage, + component: PromotionsPage, +}) + +const dashboardStarsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/stars', + beforeLoad: requireAuth, + component: MyStarsPage, +}) + +const dashboardTokensRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/tokens', + beforeLoad: requireAuth, + component: TokensPage, }) const deviceRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/device', + path: 'device', component: DeviceAuthPage, }) +const settingsSecurityRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'settings/security', + beforeLoad: requireAuth, + component: SecuritySettingsPage, +}) + +const settingsAccountsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'settings/accounts', + beforeLoad: requireAuth, + component: AccountSettingsPage, +}) + const adminUsersRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/admin/users', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } + path: 'admin/users', + beforeLoad: async (ctx) => { + const { user } = await requireAuth(ctx) if (!user.platformRoles?.includes('USER_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) { throw redirect({ to: '/dashboard' }) } @@ -223,12 +257,9 @@ const adminUsersRoute = createRoute({ const adminAuditLogRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/admin/audit-log', - beforeLoad: async () => { - const user = await getCurrentUser() - if (!user) { - throw redirect({ to: '/login' }) - } + path: 'admin/audit-log', + beforeLoad: async (ctx) => { + const { user } = await requireAuth(ctx) if (!user.platformRoles?.includes('AUDITOR') && !user.platformRoles?.includes('SUPER_ADMIN')) { throw redirect({ to: '/dashboard' }) } @@ -238,8 +269,9 @@ const adminAuditLogRoute = createRoute({ }) const routeTree = rootRoute.addChildren([ - homeRoute, + skillsRoute, loginRoute, + registerRoute, searchRoute, namespaceRoute, skillDetailRoute, @@ -248,9 +280,15 @@ const routeTree = rootRoute.addChildren([ dashboardPublishRoute, dashboardNamespacesRoute, dashboardNamespaceMembersRoute, + dashboardNamespaceReviewsRoute, dashboardReviewsRoute, dashboardReviewDetailRoute, + dashboardPromotionsRoute, + dashboardStarsRoute, + dashboardTokensRoute, deviceRoute, + settingsSecurityRoute, + settingsAccountsRoute, adminUsersRoute, adminAuditLogRoute, ]) diff --git a/web/src/features/admin/use-admin-users.ts b/web/src/features/admin/use-admin-users.ts index abc46b53..3f08b402 100644 --- a/web/src/features/admin/use-admin-users.ts +++ b/web/src/features/admin/use-admin-users.ts @@ -1,14 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { fetchJson, getCsrfHeaders } from '@/api/client' - -export interface AdminUser { - id: string - username: string - email: string - status: 'ACTIVE' | 'DISABLED' - platformRoles: string[] - createdAt: string -} +import { adminApi } from '@/api/client' +import type { AdminUser } from '@/api/types' +export type { AdminUser } from '@/api/types' export interface AdminUsersParams { search?: string @@ -25,30 +18,15 @@ export interface PagedAdminUsers { } async function getAdminUsers(params: AdminUsersParams): Promise { - const searchParams = new URLSearchParams() - if (params.search) searchParams.set('search', params.search) - if (params.status) searchParams.set('status', params.status) - searchParams.set('page', String(params.page ?? 0)) - searchParams.set('size', String(params.size ?? 20)) - - const url = `/api/v1/admin/users?${searchParams.toString()}` - return fetchJson(url) + return adminApi.getUsers(params) } async function updateUserRole(userId: string, role: string): Promise { - await fetchJson(`/api/v1/admin/users/${userId}/role`, { - method: 'PUT', - headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ role }), - }) + await adminApi.updateUserRole(userId, role) } async function updateUserStatus(userId: string, status: 'ACTIVE' | 'DISABLED'): Promise { - await fetchJson(`/api/v1/admin/users/${userId}/status`, { - method: 'PUT', - headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ status }), - }) + await adminApi.updateUserStatus(userId, status) } export function useAdminUsers(params: AdminUsersParams) { @@ -79,3 +57,33 @@ export function useUpdateUserStatus() { }, }) } + +export function useApproveUser() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.approveUser(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} + +export function useDisableUser() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.disableUser(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} + +export function useEnableUser() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.enableUser(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} diff --git a/web/src/features/admin/use-audit-log.ts b/web/src/features/admin/use-audit-log.ts index 7a2a3649..e398aca4 100644 --- a/web/src/features/admin/use-audit-log.ts +++ b/web/src/features/admin/use-audit-log.ts @@ -1,15 +1,6 @@ import { useQuery } from '@tanstack/react-query' -import { fetchJson } from '@/api/client' - -export interface AuditLog { - id: number - action: string - userId: string - username?: string - details?: string - ipAddress?: string - timestamp: string -} +import { adminApi } from '@/api/client' +import type { AuditLogItem } from '@/api/types' export interface AuditLogParams { action?: string @@ -19,21 +10,14 @@ export interface AuditLogParams { } export interface PagedAuditLogs { - items: AuditLog[] + items: AuditLogItem[] total: number page: number size: number } async function getAuditLogs(params: AuditLogParams): Promise { - const searchParams = new URLSearchParams() - if (params.action) searchParams.set('action', params.action) - if (params.userId) searchParams.set('userId', params.userId) - searchParams.set('page', String(params.page ?? 0)) - searchParams.set('size', String(params.size ?? 20)) - - const url = `/api/v1/admin/audit-logs?${searchParams.toString()}` - return fetchJson(url) + return adminApi.getAuditLogs(params) } export function useAuditLog(params: AuditLogParams) { diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index 57db8e2f..1ddb4812 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -14,7 +14,8 @@ export function LoginButton() { if (isLoading) { return (
-
@@ -26,11 +27,15 @@ export function LoginButton() { {providers.map((provider) => ( ))} diff --git a/web/src/features/auth/use-account-merge.ts b/web/src/features/auth/use-account-merge.ts new file mode 100644 index 00000000..24244384 --- /dev/null +++ b/web/src/features/auth/use-account-merge.ts @@ -0,0 +1,21 @@ +import { useMutation } from '@tanstack/react-query' +import { accountApi } from '@/api/client' +import type { MergeConfirmRequest, MergeInitiateRequest, MergeVerifyRequest } from '@/api/types' + +export function useInitiateAccountMerge() { + return useMutation({ + mutationFn: (request: MergeInitiateRequest) => accountApi.initiateMerge(request), + }) +} + +export function useVerifyAccountMerge() { + return useMutation({ + mutationFn: (request: MergeVerifyRequest) => accountApi.verifyMerge(request), + }) +} + +export function useConfirmAccountMerge() { + return useMutation({ + mutationFn: (request: MergeConfirmRequest) => accountApi.confirmMerge(request), + }) +} diff --git a/web/src/features/auth/use-local-auth.ts b/web/src/features/auth/use-local-auth.ts new file mode 100644 index 00000000..c17c5bd6 --- /dev/null +++ b/web/src/features/auth/use-local-auth.ts @@ -0,0 +1,15 @@ +import { useMutation } from '@tanstack/react-query' +import { authApi } from '@/api/client' +import type { LocalLoginRequest, LocalRegisterRequest } from '@/api/types' + +export function useLocalLogin() { + return useMutation({ + mutationFn: (request: LocalLoginRequest) => authApi.localLogin(request), + }) +} + +export function useLocalRegister() { + return useMutation({ + mutationFn: (request: LocalRegisterRequest) => authApi.localRegister(request), + }) +} diff --git a/web/src/features/promotion/use-promotion-list.ts b/web/src/features/promotion/use-promotion-list.ts new file mode 100644 index 00000000..f7521a7d --- /dev/null +++ b/web/src/features/promotion/use-promotion-list.ts @@ -0,0 +1,43 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { promotionApi } from '@/api/client' +import type { PromotionTask } from '@/api/types' + +export function usePromotionList(status = 'PENDING') { + return useQuery({ + queryKey: ['promotions', status], + queryFn: async () => { + const page = await promotionApi.list({ status }) + return page.items + }, + }) +} + +export function usePromotionDetail(id: number) { + return useQuery({ + queryKey: ['promotions', id], + queryFn: () => promotionApi.get(id), + enabled: !!id, + }) +} + +export function useApprovePromotion() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, comment }: { id: number; comment?: string }) => promotionApi.approve(id, comment), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['promotions'] }) + }, + }) +} + +export function useRejectPromotion() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, comment }: { id: number; comment?: string }) => promotionApi.reject(id, comment), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['promotions'] }) + }, + }) +} + +export type { PromotionTask } diff --git a/web/src/features/publish/upload-zone.tsx b/web/src/features/publish/upload-zone.tsx index 6ffa4ac3..f9ba9093 100644 --- a/web/src/features/publish/upload-zone.tsx +++ b/web/src/features/publish/upload-zone.tsx @@ -30,32 +30,37 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
-
- - - +
+
+ + + +
{isDragActive ? ( -

放开以上传文件...

+

放开以上传文件...

) : ( <> -

拖拽 ZIP 文件到此处,或点击选择

+

拖拽 ZIP 文件到此处,或点击选择

仅支持 .zip 格式

)} diff --git a/web/src/features/review/use-review-detail.ts b/web/src/features/review/use-review-detail.ts index aab7638f..5a70738b 100644 --- a/web/src/features/review/use-review-detail.ts +++ b/web/src/features/review/use-review-detail.ts @@ -1,29 +1,17 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { fetchJson, getCsrfHeaders } from '@/api/client' -import type { ReviewTask } from './use-review-list' +import { reviewApi } from '@/api/client' +import type { ReviewTask } from '@/api/types' async function getReviewDetail(taskId: number): Promise { - return fetchJson(`/api/v1/reviews/${taskId}`) + return reviewApi.get(taskId) } async function approveReview(taskId: number, comment?: string): Promise { - await fetchJson(`/api/v1/reviews/${taskId}/approve`, { - method: 'POST', - headers: getCsrfHeaders({ - 'Content-Type': 'application/json', - }), - body: JSON.stringify({ comment }), - }) + await reviewApi.approve(taskId, comment) } async function rejectReview(taskId: number, comment: string): Promise { - await fetchJson(`/api/v1/reviews/${taskId}/reject`, { - method: 'POST', - headers: getCsrfHeaders({ - 'Content-Type': 'application/json', - }), - body: JSON.stringify({ comment }), - }) + await reviewApi.reject(taskId, comment) } export function useReviewDetail(taskId: number) { diff --git a/web/src/features/review/use-review-list.ts b/web/src/features/review/use-review-list.ts index a0cef6fd..c0e846fe 100644 --- a/web/src/features/review/use-review-list.ts +++ b/web/src/features/review/use-review-list.ts @@ -1,29 +1,16 @@ import { useQuery } from '@tanstack/react-query' -import { fetchJson } from '@/api/client' +import { reviewApi } from '@/api/client' +import type { ReviewTask } from '@/api/types' -export interface ReviewTask { - id: number - skillVersionId: number - skillName: string - skillSlug: string - namespace: string - version: string - status: 'PENDING' | 'APPROVED' | 'REJECTED' - submittedBy: string - submittedAt: string - reviewedBy?: string - reviewedAt?: string - comment?: string +async function getReviewList(status: string, namespaceId?: number): Promise { + const page = await reviewApi.list({ status, namespaceId }) + return page.items } -async function getReviewList(status?: string): Promise { - const url = status ? `/api/v1/reviews?status=${status}` : '/api/v1/reviews' - return fetchJson(url) -} - -export function useReviewList(status?: string) { +export function useReviewList(status: string, namespaceId?: number) { return useQuery({ - queryKey: ['reviews', status], - queryFn: () => getReviewList(status), + queryKey: ['reviews', status, namespaceId], + queryFn: () => getReviewList(status, namespaceId), + enabled: namespaceId === undefined || namespaceId > 0, }) } diff --git a/web/src/features/search/search-bar.tsx b/web/src/features/search/search-bar.tsx index ba2f6643..a5c07ee3 100644 --- a/web/src/features/search/search-bar.tsx +++ b/web/src/features/search/search-bar.tsx @@ -1,4 +1,4 @@ -import { useState, type FormEvent } from 'react' +import { useState } from 'react' import { Input } from '@/shared/ui/input' import { Button } from '@/shared/ui/button' @@ -11,7 +11,7 @@ interface SearchBarProps { export function SearchBar({ defaultValue = '', placeholder = '搜索技能...', onSearch }: SearchBarProps) { const [query, setQuery] = useState(defaultValue) - const handleSubmit = (e: FormEvent) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (onSearch) { onSearch(query) @@ -19,15 +19,32 @@ export function SearchBar({ defaultValue = '', placeholder = '搜索技能...', } return ( -
- setQuery(e.target.value)} - placeholder={placeholder} - className="flex-1" - /> - + +
+ + + + setQuery(e.target.value)} + placeholder={placeholder} + className="pl-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12" + /> +
+
) } diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 32b919e5..6b164983 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -1,5 +1,7 @@ import ReactMarkdown from 'react-markdown' import rehypeHighlight from 'rehype-highlight' +import rehypeSanitize from 'rehype-sanitize' +import remarkGfm from 'remark-gfm' interface MarkdownRendererProps { content: string @@ -13,7 +15,9 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) return (
- {content} + + {content} +
) } diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index ab52ac80..e688abc5 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -10,28 +10,47 @@ interface SkillCardProps { export function SkillCard({ skill, onClick }: SkillCardProps) { return ( -
-

{skill.displayName}

- -
+ {/* Hover gradient border effect */} +
- {skill.summary && ( -

- {skill.summary} -

- )} +
+
+

+ {skill.displayName} +

+ +
-
- {skill.latestVersion && ( - v{skill.latestVersion} - )} - {skill.downloadCount} 下载 - {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( - ⭐ {skill.ratingAvg.toFixed(1)} ({skill.ratingCount}) + {skill.summary && ( +

+ {skill.summary} +

)} + +
+ {skill.latestVersion && ( + + v{skill.latestVersion} + + )} + + + + + {skill.downloadCount} + + {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( + + + + + {skill.ratingAvg.toFixed(1)} ({skill.ratingCount}) + + )} +
) diff --git a/web/src/features/social/rating-input.tsx b/web/src/features/social/rating-input.tsx index e4245d65..4239a40b 100644 --- a/web/src/features/social/rating-input.tsx +++ b/web/src/features/social/rating-input.tsx @@ -1,19 +1,26 @@ import { useState } from 'react' import { Star } from 'lucide-react' import { useUserRating, useRate } from './use-rating' +import { useAuth } from '@/features/auth/use-auth' interface RatingInputProps { skillId: number + onRequireLogin?: () => void } -export function RatingInput({ skillId }: RatingInputProps) { +export function RatingInput({ skillId, onRequireLogin }: RatingInputProps) { const { data: userRating, isLoading } = useUserRating(skillId) const rateMutation = useRate(skillId) + const { isAuthenticated } = useAuth() const [hoveredRating, setHoveredRating] = useState(null) - const currentRating = userRating?.rating || 0 + const currentRating = userRating?.rated ? userRating.score : 0 const handleRate = (rating: number) => { + if (!isAuthenticated) { + onRequireLogin?.() + return + } rateMutation.mutate(rating) } diff --git a/web/src/features/social/star-button.tsx b/web/src/features/social/star-button.tsx index 6424fddc..e6767d0a 100644 --- a/web/src/features/social/star-button.tsx +++ b/web/src/features/social/star-button.tsx @@ -1,16 +1,24 @@ import { Button } from '@/shared/ui/button' import { useStar, useToggleStar } from './use-star' import { Star } from 'lucide-react' +import { useAuth } from '@/features/auth/use-auth' interface StarButtonProps { skillId: number + starCount: number + onRequireLogin?: () => void } -export function StarButton({ skillId }: StarButtonProps) { +export function StarButton({ skillId, starCount, onRequireLogin }: StarButtonProps) { const { data: starStatus, isLoading } = useStar(skillId) const toggleMutation = useToggleStar(skillId) + const { isAuthenticated } = useAuth() const handleToggle = () => { + if (!isAuthenticated) { + onRequireLogin?.() + return + } if (starStatus) { toggleMutation.mutate(starStatus.starred) } @@ -28,7 +36,7 @@ export function StarButton({ skillId }: StarButtonProps) { disabled={toggleMutation.isPending} > - {starStatus.starred ? '已收藏' : '收藏'} ({starStatus.starCount}) + {starStatus.starred ? '已收藏' : '收藏'} ({starCount}) ) } diff --git a/web/src/features/social/use-rating.ts b/web/src/features/social/use-rating.ts index b97d33e2..403c905c 100644 --- a/web/src/features/social/use-rating.ts +++ b/web/src/features/social/use-rating.ts @@ -2,12 +2,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { fetchJson, getCsrfHeaders } from '@/api/client' interface UserRating { - rating?: number - ratedAt?: string + score: number + rated: boolean } async function getUserRating(skillId: number): Promise { - return fetchJson(`/api/v1/skills/${skillId}/rating`) + try { + return await fetchJson(`/api/v1/skills/${skillId}/rating`) + } catch (error) { + if (error instanceof Error && error.message === 'HTTP 401') { + return { score: 0, rated: false } + } + throw error + } } async function rateSkill(skillId: number, rating: number): Promise { @@ -16,7 +23,7 @@ async function rateSkill(skillId: number, rating: number): Promise { headers: getCsrfHeaders({ 'Content-Type': 'application/json', }), - body: JSON.stringify({ rating }), + body: JSON.stringify({ score: rating }), }) } diff --git a/web/src/features/social/use-star.ts b/web/src/features/social/use-star.ts index 89610107..3e9aed97 100644 --- a/web/src/features/social/use-star.ts +++ b/web/src/features/social/use-star.ts @@ -3,11 +3,18 @@ import { fetchJson, getCsrfHeaders } from '@/api/client' interface StarStatus { starred: boolean - starCount: number } async function getStarStatus(skillId: number): Promise { - return fetchJson(`/api/v1/skills/${skillId}/star`) + try { + const starred = await fetchJson(`/api/v1/skills/${skillId}/star`) + return { starred } + } catch (error) { + if (error instanceof Error && error.message === 'HTTP 401') { + return { starred: false } + } + throw error + } } async function toggleStar(skillId: number, starred: boolean): Promise { diff --git a/web/src/features/token/token-list.tsx b/web/src/features/token/token-list.tsx index a73492bb..1b2787d6 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -33,7 +33,7 @@ export function TokenList() { } } - const formatDate = (dateString: string | null) => { + const formatDate = (dateString?: string | null) => { if (!dateString) return '-' return new Date(dateString).toLocaleString('zh-CN') } diff --git a/web/src/index.css b/web/src/index.css index 10c2d37f..08ae81a0 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -4,48 +4,63 @@ @layer base { :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; + /* Aurora Tech — light theme (default) */ + --background: 210 40% 97%; + --foreground: 222 47% 11%; --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + --card-foreground: 222 47% 11%; --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; - --radius: 0.5rem; + --popover-foreground: 222 47% 11%; + /* Cyan primary */ + --primary: 192 80% 42%; + --primary-foreground: 0 0% 100%; + /* Surface tones */ + --secondary: 210 30% 93%; + --secondary-foreground: 222 47% 11%; + --muted: 210 25% 92%; + --muted-foreground: 215 16% 42%; + /* Violet accent */ + --accent: 263 60% 58%; + --accent-foreground: 0 0% 100%; + --destructive: 0 72% 55%; + --destructive-foreground: 0 0% 100%; + --border: 214 20% 88%; + --input: 214 20% 88%; + --ring: 192 80% 42%; + --radius: 0.75rem; + + /* Extended palette */ + --surface-glass: 210 30% 95%; + --glow-primary: 192 80% 42%; + --glow-accent: 263 60% 58%; + --success: 160 60% 45%; + --warning: 38 92% 58%; } .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; + /* Aurora Tech — deep navy dark theme */ + --background: 222 47% 6%; + --foreground: 210 40% 96%; + --card: 222 40% 9%; + --card-foreground: 210 40% 96%; + --popover: 222 40% 9%; + --popover-foreground: 210 40% 96%; + --primary: 192 91% 56%; + --primary-foreground: 222 47% 6%; + --secondary: 222 30% 13%; + --secondary-foreground: 210 30% 85%; + --muted: 222 25% 15%; + --muted-foreground: 215 20% 55%; + --accent: 263 70% 70%; + --accent-foreground: 0 0% 100%; + --destructive: 0 72% 55%; + --destructive-foreground: 0 0% 100%; + --border: 222 20% 18%; + --input: 222 20% 18%; + --ring: 192 91% 56%; + --surface-glass: 222 35% 11%; + --glow-primary: 192 91% 56%; + --glow-accent: 263 70% 70%; } } @@ -53,7 +68,207 @@ * { @apply border-border; } + + html { + scroll-behavior: smooth; + } + body { - @apply bg-background text-foreground; + @apply bg-background text-foreground antialiased; + font-family: 'IBM Plex Sans', system-ui, sans-serif; + } + + h1, h2, h3, h4, h5, h6 { + font-family: 'Syne', 'IBM Plex Sans', system-ui, sans-serif; + } + + code, pre, kbd { + font-family: 'JetBrains Mono', ui-monospace, monospace; } } + +/* ─── Dot-grid background texture ─── */ +.bg-dots { + background-image: radial-gradient(circle, hsl(var(--muted-foreground) / 0.12) 1px, transparent 1px); + background-size: 24px 24px; +} + +/* ─── Gradient glow orbs ─── */ +.glow-orb-primary { + position: fixed; + width: 600px; + height: 600px; + border-radius: 50%; + background: radial-gradient(circle, hsl(var(--glow-primary) / 0.08) 0%, transparent 70%); + pointer-events: none; + z-index: 0; +} + +.glow-orb-accent { + position: fixed; + width: 500px; + height: 500px; + border-radius: 50%; + background: radial-gradient(circle, hsl(var(--glow-accent) / 0.06) 0%, transparent 70%); + pointer-events: none; + z-index: 0; +} + +/* ─── Glass morphism ─── */ +.glass { + background: hsl(var(--surface-glass) / 0.7); + backdrop-filter: blur(16px) saturate(1.4); + -webkit-backdrop-filter: blur(16px) saturate(1.4); + border: 1px solid hsl(var(--border) / 0.5); +} + +.glass-strong { + background: hsl(var(--surface-glass) / 0.85); + backdrop-filter: blur(24px) saturate(1.6); + -webkit-backdrop-filter: blur(24px) saturate(1.6); + border: 1px solid hsl(var(--border) / 0.6); +} + +/* ─── Animations ─── */ +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slide-in-right { + from { + opacity: 0; + transform: translateX(16px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } +} + +@keyframes pulse-glow { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } +} + +.animate-fade-up { + animation: fade-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.animate-fade-in { + animation: fade-in 0.5s ease both; +} + +.animate-slide-in-right { + animation: slide-in-right 0.5s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.animate-shimmer { + background: linear-gradient( + 90deg, + hsl(var(--muted)) 25%, + hsl(var(--muted-foreground) / 0.08) 50%, + hsl(var(--muted)) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.8s ease-in-out infinite; +} + +.animate-float { + animation: float 6s ease-in-out infinite; +} + +/* Stagger delays */ +.delay-1 { animation-delay: 0.1s; } +.delay-2 { animation-delay: 0.2s; } +.delay-3 { animation-delay: 0.3s; } +.delay-4 { animation-delay: 0.4s; } +.delay-5 { animation-delay: 0.5s; } +.delay-6 { animation-delay: 0.6s; } + +/* ─── Gradient text ─── */ +.text-gradient-primary { + background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--primary) / 0.7)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.text-gradient-hero { + background: linear-gradient(135deg, hsl(192 80% 38%), hsl(192 80% 42%), hsl(263 60% 52%)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.dark .text-gradient-hero { + background: linear-gradient(135deg, hsl(192 95% 65%), hsl(192 91% 56%), hsl(263 70% 70%)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ─── Card hover lift ─── */ +.card-hover { + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), + box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1), + border-color 0.3s ease; +} + +.card-hover:hover { + transform: translateY(-4px); + box-shadow: 0 20px 40px -12px hsl(var(--primary) / 0.1), + 0 8px 16px -8px hsl(0 0% 0% / 0.2); + border-color: hsl(var(--primary) / 0.3); +} + +/* ─── Scrollbar ─── */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: hsl(var(--background)); +} + +::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.3); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.5); +} + +/* ─── Selection ─── */ +::selection { + background: hsl(var(--primary) / 0.3); + color: hsl(var(--foreground)); +} + +/* ─── Focus ring ─── */ +.focus-ring { + @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background; +} diff --git a/web/src/pages/admin/audit-log.tsx b/web/src/pages/admin/audit-log.tsx index 67399088..07fe92ef 100644 --- a/web/src/pages/admin/audit-log.tsx +++ b/web/src/pages/admin/audit-log.tsx @@ -30,22 +30,22 @@ export function AuditLogPage() { } return ( -
+
-

审计日志

-

查看系统操作记录

+

审计日志

+

查看系统操作记录

- +
{isLoading ? ( -
加载中...
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
) : !data || data.items.length === 0 ? (

暂无审计日志

@@ -81,11 +85,11 @@ export function AuditLogPage() { {formatDate(log.timestamp)} {log.action} - {log.userId} - {log.username || '-'} + {log.userId || '-'} + {log.resourceType || '-'} {log.ipAddress || '-'} - {log.details || '-'} + {log.resourceId || '-'} ))} diff --git a/web/src/pages/admin/users.tsx b/web/src/pages/admin/users.tsx index 725f7705..77dcee1c 100644 --- a/web/src/pages/admin/users.tsx +++ b/web/src/pages/admin/users.tsx @@ -20,7 +20,7 @@ import { DialogTitle, } from '@/shared/ui/dialog' import { Label } from '@/shared/ui/label' -import { useAdminUsers, useUpdateUserRole, useUpdateUserStatus } from '@/features/admin/use-admin-users' +import { useAdminUsers, useApproveUser, useDisableUser, useEnableUser, useUpdateUserRole } from '@/features/admin/use-admin-users' import type { AdminUser } from '@/features/admin/use-admin-users' export function AdminUsersPage() { @@ -41,7 +41,9 @@ export function AdminUsersPage() { }) const updateRoleMutation = useUpdateUserRole() - const updateStatusMutation = useUpdateUserStatus() + const approveUserMutation = useApproveUser() + const disableUserMutation = useDisableUser() + const enableUserMutation = useEnableUser() const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString('zh-CN') @@ -62,7 +64,7 @@ export function AdminUsersPage() { const confirmRoleChange = async () => { if (!selectedUser) return try { - await updateRoleMutation.mutateAsync({ userId: selectedUser.id, role: newRole }) + await updateRoleMutation.mutateAsync({ userId: selectedUser.userId, role: newRole }) setRoleDialogOpen(false) setSelectedUser(null) } catch (error) { @@ -73,8 +75,11 @@ export function AdminUsersPage() { const confirmStatusChange = async () => { if (!selectedUser) return try { - const newStatus = actionType === 'ban' ? 'DISABLED' : 'ACTIVE' - await updateStatusMutation.mutateAsync({ userId: selectedUser.id, status: newStatus }) + if (actionType === 'ban') { + await disableUserMutation.mutateAsync(selectedUser.userId) + } else { + await enableUserMutation.mutateAsync(selectedUser.userId) + } setConfirmDialogOpen(false) setSelectedUser(null) } catch (error) { @@ -83,13 +88,13 @@ export function AdminUsersPage() { } return ( -
+
-

用户管理

-

管理平台用户和权限

+

用户管理

+

管理平台用户和权限

- +
setStatusFilter(e.target.value)}> +
{isLoading ? ( -
加载中...
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
) : !data || data.items.length === 0 ? (

暂无用户数据

@@ -127,18 +137,20 @@ export function AdminUsersPage() { {data.items.map((user) => ( - + {user.username} - {user.email} + {user.email || '-'} - {user.status === 'ACTIVE' ? '活跃' : '已禁用'} + {user.status === 'ACTIVE' ? '活跃' : user.status === 'PENDING' ? '待审批' : '已禁用'} {user.platformRoles.join(', ')} @@ -152,6 +164,15 @@ export function AdminUsersPage() { > 修改角色 + {user.status === 'PENDING' && ( + + )} {user.status === 'ACTIVE' ? ( - diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index 5116fca0..768b413f 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -1,3 +1,4 @@ +import { Link } from '@tanstack/react-router' import { useAuth } from '@/features/auth/use-auth' import { TokenList } from '@/features/token/token-list' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' @@ -6,10 +7,10 @@ export function DashboardPage() { const { user } = useAuth() return ( -
+
-

Dashboard

-

+

Dashboard

+

管理你的账户和 API Tokens

@@ -19,31 +20,32 @@ export function DashboardPage() { 用户信息 你的账户详情 - -
+ +
{user?.avatarUrl && ( {user.displayName} )} -
-
{user?.displayName}
+
+
{user?.displayName}
{user?.email}
-
+
+ 通过 {user?.oauthProvider} 登录
{user?.platformRoles && user.platformRoles.length > 0 && ( -
-
平台角色
+
+
平台角色
- {user.platformRoles.map((role) => ( + {user.platformRoles.map((role: string) => ( {role} @@ -54,6 +56,27 @@ export function DashboardPage() { +
+ +
收藏与评分
+ + 查看我的收藏 + +
+ +
访问凭证
+ + 打开 Token 页面 + +
+ +
审核与治理
+ + 查看提升审核 + +
+
+
) diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index cb58340f..833cf0fb 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -18,55 +18,77 @@ export function MyNamespacesPage() { navigate({ to: '/dashboard/namespaces/$slug/members', params: { slug } }) } + const handleReviewsClick = (slug: string, e: React.MouseEvent) => { + e.stopPropagation() + navigate({ to: '/dashboard/namespaces/$slug/reviews', params: { slug } }) + } + if (isLoading) { - return
加载中...
+ return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) } return ( -
+
-

我的命名空间

-

管理你的命名空间和团队

+

我的命名空间

+

管理你的命名空间和团队

{namespaces && namespaces.length > 0 ? ( -
- {namespaces.map((namespace) => ( +
+ {namespaces.map((namespace, idx) => ( handleNamespaceClick(namespace.slug)} > -
+
-
-

{namespace.displayName}

+
+

+ {namespace.displayName} +

{namespace.description && ( -

+

{namespace.description}

)} -
@{namespace.slug}
+
@{namespace.slug}
- {namespace.type === 'TEAM' && ( +
+ {namespace.type === 'TEAM' && ( + + )} - )} +
))} diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index abe5f932..39e3ac4b 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -13,41 +13,59 @@ export function MySkillsPage() { } if (isLoading) { - return
加载中...
+ return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) } return ( -
+
-

我的技能

-

管理你发布的技能

+

我的技能

+

管理你发布的技能

-
{skills && skills.length > 0 ? (
- {skills.map((skill) => ( + {skills.map((skill, idx) => ( handleSkillClick(skill.namespace, skill.slug)} >
-

{skill.displayName}

+

+ {skill.displayName} +

{skill.summary && ( -

{skill.summary}

+

{skill.summary}

)}
- @{skill.namespace} - {skill.latestVersion && v{skill.latestVersion}} - {skill.downloadCount} 下载 + @{skill.namespace} + {skill.latestVersion && ( + v{skill.latestVersion} + )} + + + + + {skill.downloadCount} +
+ + +
))} @@ -57,7 +75,7 @@ export function MySkillsPage() { title="还没有技能" description="开始发布你的第一个技能吧" action={ - } diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx index c1326cc2..b764761b 100644 --- a/web/src/pages/dashboard/namespace-members.tsx +++ b/web/src/pages/dashboard/namespace-members.tsx @@ -11,43 +11,56 @@ export function NamespaceMembersPage() { const { data: members, isLoading: isLoadingMembers } = useNamespaceMembers(slug) if (isLoadingNamespace) { - return
加载中...
+ return ( +
+
+
+
+ ) } if (!namespace) { - return
命名空间不存在
+ return ( +
+

命名空间不存在

+
+ ) } return ( -
+
-
+
-

成员管理

+

成员管理

{isLoadingMembers ? ( -
加载中...
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
) : members && members.length > 0 ? ( - +
- - - - - + + + + + {members.map((member) => ( - - + + diff --git a/web/src/pages/dashboard/namespace-reviews.tsx b/web/src/pages/dashboard/namespace-reviews.tsx new file mode 100644 index 00000000..c420b6c6 --- /dev/null +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -0,0 +1,65 @@ +import { useParams } from '@tanstack/react-router' +import { Card } from '@/shared/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +import { useNamespaceDetail } from '@/shared/hooks/use-skill-queries' +import { useReviewList } from '@/features/review/use-review-list' + +function ReviewListSection({ namespaceId }: { namespaceId?: number }) { + const { data: pending } = useReviewList('PENDING', namespaceId) + const { data: approved } = useReviewList('APPROVED', namespaceId) + const { data: rejected } = useReviewList('REJECTED', namespaceId) + + const renderItems = (items?: typeof pending) => { + if (!items || items.length === 0) { + return 暂无审核记录 + } + return ( + + {items.map((review) => ( +
+
+
+
{review.namespace}/{review.skillSlug}
+
版本 {review.version}
+
+
{new Date(review.submittedAt).toLocaleString('zh-CN')}
+
+ {review.reviewComment ? ( +

{review.reviewComment}

+ ) : null} +
+ ))} +
+ ) + } + + return ( + + + 待审核 + 已通过 + 已拒绝 + + {renderItems(pending)} + {renderItems(approved)} + {renderItems(rejected)} + + ) +} + +export function NamespaceReviewsPage() { + const { slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews' }) + const { data: namespace } = useNamespaceDetail(slug) + + return ( +
+
+

命名空间审核

+

+ {namespace ? `${namespace.displayName} 的审核任务` : '加载命名空间信息中'} +

+
+ +
+ ) +} diff --git a/web/src/pages/dashboard/promotions.tsx b/web/src/pages/dashboard/promotions.tsx new file mode 100644 index 00000000..469047de --- /dev/null +++ b/web/src/pages/dashboard/promotions.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react' +import { useApprovePromotion, usePromotionList, useRejectPromotion } from '@/features/promotion/use-promotion-list' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' + +function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECTED' }) { + const { data: items, isLoading } = usePromotionList(status) + const approveMutation = useApprovePromotion() + const rejectMutation = useRejectPromotion() + const [commentById, setCommentById] = useState>({}) + + if (isLoading) { + return
+ } + + if (!items || items.length === 0) { + return 暂无提升申请 + } + + return ( +
+ {items.map((item) => ( + +
+
+
{item.sourceNamespace}/{item.sourceSkillSlug}
+
+ {item.sourceVersion} {'->'} @{item.targetNamespace} +
+
+
{new Date(item.submittedAt).toLocaleString('zh-CN')}
+
+ {status === 'PENDING' ? ( + <> + setCommentById((prev) => ({ ...prev, [item.id]: event.target.value }))} + /> +
+ + +
+ + ) : item.reviewComment ? ( +

{item.reviewComment}

+ ) : null} +
+ ))} +
+ ) +} + +export function PromotionsPage() { + return ( +
+
+

提升审核

+

审核团队技能提升到全局空间的申请

+
+ + + 待审核 + 已通过 + 已拒绝 + + + + + +
+ ) +} diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 1303d4b3..2481861a 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -36,18 +36,18 @@ export function PublishPage() { } return ( -
+
-

发布技能

-

上传技能包到 SkillHub

+

发布技能

+

上传技能包到 SkillHub

- + {/* Namespace Selector */} -
- +
+ {isLoadingNamespaces ? ( -
加载中...
+
) : ( {/* Upload Zone */} -
- +
+ {selectedFile && ( -
- 已选择: {selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB) +
+ + + + {selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
)}
@@ -95,11 +98,11 @@ export function PublishPage() { {/* Publish Button */} + {publishMutation.isPending ? '发布中...' : '确认发布'}
) diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx index b3746dbb..3d74e10a 100644 --- a/web/src/pages/dashboard/review-detail.tsx +++ b/web/src/pages/dashboard/review-detail.tsx @@ -53,18 +53,27 @@ export function ReviewDetailPage() { } if (isLoading) { - return
加载中...
+ return ( +
+
+
+
+ ) } if (!review) { - return
审核任务不存在
+ return ( +
+

审核任务不存在

+
+ ) } return ( -
+
-

审核详情

+

审核详情

审核 ID: {review.id}

- -
-
- -

{review.skillName}

+ +
+
+ +

{review.namespace}/{review.skillSlug}

-
- -

{review.namespace}/{review.skillSlug}

-
-
- -

{review.version}

-
-
- -

- {review.status === 'PENDING' && '待审核'} - {review.status === 'APPROVED' && '已通过'} - {review.status === 'REJECTED' && '已拒绝'} +

+ +

+ + {review.version} +

-
- -

{review.submittedBy}

+
+ +

+ {review.status === 'PENDING' && ( + 待审核 + )} + {review.status === 'APPROVED' && ( + 已通过 + )} + {review.status === 'REJECTED' && ( + 已拒绝 + )} +

-
- -

{formatDate(review.submittedAt)}

+
+ +

{review.submittedByName || review.submittedBy}

+
+
+ +

{formatDate(review.submittedAt)}

{review.reviewedBy && ( <> -
- -

{review.reviewedBy}

+
+ +

{review.reviewedByName || review.reviewedBy}

-
- -

- {review.reviewedAt ? formatDate(review.reviewedAt) : '-'} +

+ +

+ {review.reviewedAt ? formatDate(review.reviewedAt) : '—'}

)}
- {review.comment && ( -
- -

{review.comment}

+ {review.reviewComment && ( +
+ +

{review.reviewComment}

)} {review.status === 'PENDING' && ( - -

审核操作

+ +

审核操作

-
- +
+
用户 ID角色加入时间操作
用户 ID角色加入时间操作
{member.userId}
{member.userId} - + {member.role}