commit 8de82343725aba21cb3b3eace87f71cf00929422 Author: loveuer Date: Sun Nov 9 15:19:11 2025 +0800 wip: oci image management diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a242a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/ +dist/ + +# Test binary +*.test + +# Output of the go coverage tool +*.out + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# Storage directory +storage/ +x-storage/ +*.db +main +/tmp/cluster-test + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# Logs +*.log +cluster-test diff --git a/README.md b/README.md new file mode 100644 index 0000000..097c4e4 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# Cluster - OCI Registry + +A lightweight OCI (Open Container Initiative) registry implementation written in Go using Fiber v3. + +## Features + +- OCI Registry API v2 compliant +- Blob upload/download with chunked upload support +- Manifest handling (Docker and OCI formats) +- Tag management and catalog listing +- SQLite database for metadata storage +- File system storage for blobs and manifests +- RESTful API v1 for image management + +## Architecture + +- **main.go**: Application entry point +- **api/**: API route definitions +- **handler/**: HTTP request handlers +- **controller/**: Business logic controllers +- **internal/**: Internal packages (config, database, middleware, model, rerr, storage) + +## Development + +### Backend (Go) + +```bash +# Build +go mod tidy +go build -o cluster . + +# Run +./cluster -debug -address 0.0.0.0:8080 -data-dir ./x-storage + +# Or use Makefile +make build +make run +``` + +### Frontend (React/TypeScript) + +```bash +cd frontend + +# Install dependencies (requires pnpm) +pnpm install + +# Approve build scripts (required for esbuild) +pnpm approve-builds + +# Development server +pnpm dev + +# Build for production +pnpm run build +``` + +**Note**: This project uses `pnpm` as the package manager. Install it if needed: +```bash +npm install -g pnpm +# or +curl -fsSL https://get.pnpm.io/install.sh | sh - +``` + +## API Endpoints + +### OCI Registry API v2 + +- `GET /v2/` - Version check +- `POST /v2/{repo}/blobs/uploads/` - Start blob upload +- `PATCH /v2/{repo}/blobs/uploads/{uuid}` - Upload blob chunk +- `PUT /v2/{repo}/blobs/uploads/{uuid}?digest={digest}` - Complete blob upload +- `GET /v2/{repo}/blobs/{digest}` - Get blob +- `HEAD /v2/{repo}/blobs/{digest}` - Check blob existence +- `PUT /v2/{repo}/manifests/{tag}` - Put manifest +- `GET /v2/{repo}/manifests/{tag}` - Get manifest +- `DELETE /v2/{repo}/manifests/{tag}` - Delete manifest +- `GET /v2/{repo}/tags/list` - List tags +- `GET /v2/_catalog` - List repositories + +### API v1 + +- `GET /api/v1/registry/image/list` - List images + +## Configuration + +- `-debug`: Enable debug logging +- `-address`: Server address (default: 0.0.0.0:8080) +- `-data-dir`: Data directory for storage (default: ./x-storage) + +## License + +[Add your license here] diff --git a/api/routes.go b/api/routes.go new file mode 100644 index 0000000..0432b55 --- /dev/null +++ b/api/routes.go @@ -0,0 +1,19 @@ +package api + +import ( + "gitea.loveuer.com/loveuer/cluster/api/v1" + "gitea.loveuer.com/loveuer/cluster/handler" + "gitea.loveuer.com/loveuer/cluster/internal/registry/storage" + + "github.com/gofiber/fiber/v3" +) + +// SetupRoutes 设置所有路由 +func SetupRoutes(app *fiber.App, store storage.Storage) { + // REST API v1 + api := app.Group("/api/v1") + v1.SetupRoutes(api) + + // OCI Registry API v2 - 使用通配符路由处理所有 /v2/* 路径 + app.All("/v2/*", handler.RegistryV2(store)) +} diff --git a/api/v1/routes.go b/api/v1/routes.go new file mode 100644 index 0000000..abfd9fd --- /dev/null +++ b/api/v1/routes.go @@ -0,0 +1,13 @@ +package v1 + +import ( + "gitea.loveuer.com/loveuer/cluster/handler" + + "github.com/gofiber/fiber/v3" +) + +// SetupRoutes 设置 v1 API 路由 +func SetupRoutes(api fiber.Router) { + reg := api.Group("/registry") + reg.Get("/image/list", handler.ListImages) +} diff --git a/controller/blob.go b/controller/blob.go new file mode 100644 index 0000000..64f8201 --- /dev/null +++ b/controller/blob.go @@ -0,0 +1,27 @@ +package controller + +import ( + "io" + + "gitea.loveuer.com/loveuer/cluster/internal/registry/storage" +) + +// GetBlob 获取 blob +func GetBlob(store storage.Storage, repo, digest string) (io.ReadCloser, int64, error) { + return store.GetBlob(repo, digest) +} + +// HeadBlob 检查 blob 是否存在并返回大小 +func HeadBlob(store storage.Storage, repo, digest string) (bool, int64, error) { + exists, err := store.BlobExists(repo, digest) + if err != nil || !exists { + return false, 0, err + } + + size, err := store.GetBlobSize(repo, digest) + if err != nil { + return false, 0, err + } + + return true, size, nil +} diff --git a/controller/manifest.go b/controller/manifest.go new file mode 100644 index 0000000..b81d1a8 --- /dev/null +++ b/controller/manifest.go @@ -0,0 +1,57 @@ +package controller + +import ( + "crypto/sha256" + "encoding/hex" + + "gitea.loveuer.com/loveuer/cluster/internal/registry/storage" +) + +// GetManifest 获取 manifest +func GetManifest(store storage.Storage, repo, reference string) ([]byte, string, string, error) { + data, mediaType, err := store.GetManifest(repo, reference) + if err != nil { + return nil, "", "", err + } + + digest := calculateDigest(data) + return data, mediaType, digest, nil +} + +// PutManifest 推送 manifest +func PutManifest(store storage.Storage, repo, reference string, data []byte, mediaType string) (string, error) { + if err := store.PutManifest(repo, reference, data, mediaType); err != nil { + return "", err + } + + digest := calculateDigest(data) + return digest, nil +} + +// DeleteManifest 删除 manifest +func DeleteManifest(store storage.Storage, repo, reference string) error { + return store.DeleteManifest(repo, reference) +} + +// HeadManifest 检查 manifest 是否存在并返回元数据 +func HeadManifest(store storage.Storage, repo, reference string) (bool, []byte, string, string, error) { + exists, err := store.ManifestExists(repo, reference) + if err != nil || !exists { + return false, nil, "", "", err + } + + // 获取 manifest 以设置正确的 headers + data, mediaType, err := store.GetManifest(repo, reference) + if err != nil { + return false, nil, "", "", err + } + + digest := calculateDigest(data) + return true, data, mediaType, digest, nil +} + +// calculateDigest 计算 SHA256 digest +func calculateDigest(data []byte) string { + hash := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(hash[:]) +} diff --git a/controller/registry.go b/controller/registry.go new file mode 100644 index 0000000..2c425fc --- /dev/null +++ b/controller/registry.go @@ -0,0 +1,14 @@ +package controller + +import ( + "gitea.loveuer.com/loveuer/cluster/internal/database" +) + +// ListImages 返回所有仓库列表 +func ListImages() ([]database.Repository, error) { + var repos []database.Repository + if err := database.DB.Find(&repos).Error; err != nil { + return nil, err + } + return repos, nil +} diff --git a/controller/upload.go b/controller/upload.go new file mode 100644 index 0000000..ebe1173 --- /dev/null +++ b/controller/upload.go @@ -0,0 +1,64 @@ +package controller + +import ( + "io" + "strings" + + "gitea.loveuer.com/loveuer/cluster/internal/registry/storage" +) + +// StartBlobUpload 开始 blob 上传 +func StartBlobUpload(store storage.Storage, repo string) (string, error) { + return store.StartBlobUpload(repo) +} + +// PatchBlobUpload 上传 blob 数据块 +func PatchBlobUpload(store storage.Storage, uuid string, data io.Reader) (int64, error) { + if err := store.PutBlobUploadChunk(uuid, data); err != nil { + return 0, err + } + + // 获取当前上传大小 + size, err := store.GetBlobUpload(uuid) + if err != nil { + return 0, err + } + + return size, nil +} + +// PutBlobUpload 完成 blob 上传 +func PutBlobUpload(store storage.Storage, repo, uuid, digest string, data interface{}, requestPath string) (string, error) { + // 如果有请求体,先追加数据 + if data != nil { + if reader, ok := data.(io.Reader); ok { + if err := store.PutBlobUploadChunk(uuid, reader); err != nil { + return "", err + } + } + } + + // 完成上传 + if err := store.CompleteBlobUpload(repo, uuid, digest); err != nil { + return "", err + } + + // 清理上传文件 + store.DeleteBlobUpload(uuid) + + // 返回 blob 位置 + // 从 /v2/{name}/blobs/uploads/{uuid} 转换为 /v2/{name}/blobs/{digest} + pathParts := strings.Split(requestPath, "/") + if len(pathParts) >= 4 { + // 构建新的路径: /v2/{name}/blobs/{digest} + location := "/v2/" + pathParts[2] + "/blobs/" + digest + return location, nil + } + + return requestPath, nil +} + +// GetBlobUpload 获取上传状态 +func GetBlobUpload(store storage.Storage, uuid string) (int64, error) { + return store.GetBlobUpload(uuid) +} diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..3c8a361 --- /dev/null +++ b/dev.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Run backend (Go) and frontend (Vite) together; stop both on Ctrl+C +set -euo pipefail + +# Always run from repo root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +BACKEND_ADDR=${BACKEND_ADDR:-0.0.0.0:8080} +DATA_DIR=${DATA_DIR:-./x-storage} + +cleanup() { + echo "\n[dev] Shutting down..." + # Kill all background jobs spawned by this script + jobs -p | xargs -r kill 2>/dev/null || true + # Extra wait to ensure graceful shutdown + wait || true +} +trap cleanup INT TERM + +# Start backend +( + echo "[dev] Starting backend on $BACKEND_ADDR (data-dir=$DATA_DIR)" + go run cmd/server/main.go -debug -address "$BACKEND_ADDR" -data-dir "$DATA_DIR" +) & +BACKEND_PID=$! + +# Start frontend +( + echo "[dev] Starting frontend dev server (Vite)" + npm --prefix frontend run dev +) & +FRONTEND_PID=$! + +echo "[dev] Backend PID: $BACKEND_PID, Frontend PID: $FRONTEND_PID" +echo "[dev] Press Ctrl+C to stop both..." + +# Wait for both processes to exit (trap will handle Ctrl+C) +wait $BACKEND_PID $FRONTEND_PID diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..d6c9537 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7ddb9e2 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,77 @@ +# Cluster Frontend + +基于 React + TypeScript + Zustand + MUI 的前端项目。 + +## 技术栈 + +- **React 18** - UI 框架 +- **TypeScript** - 类型系统 +- **Vite** - 构建工具 +- **Zustand** - 状态管理 +- **Material-UI (MUI)** - UI 组件库 + +## 开发 + +### 安装依赖 + +```bash +npm install +# 或 +yarn install +# 或 +pnpm install +``` + +### 启动开发服务器 + +```bash +npm run dev +# 或 +yarn dev +# 或 +pnpm dev +``` + +开发服务器将在 `http://localhost:3000` 启动。 + +### 构建生产版本 + +```bash +npm run build +# 或 +yarn build +# 或 +pnpm build +``` + +### 预览生产构建 + +```bash +npm run preview +# 或 +yarn preview +# 或 +pnpm preview +``` + +## 项目结构 + +``` +frontend/ +├── src/ +│ ├── stores/ # Zustand 状态管理 +│ ├── components/ # React 组件 +│ ├── theme.ts # MUI 主题配置 +│ ├── App.tsx # 主应用组件 +│ └── main.tsx # 应用入口 +├── public/ # 静态资源 +├── index.html # HTML 模板 +├── vite.config.ts # Vite 配置 +└── tsconfig.json # TypeScript 配置 +``` + +## API 代理 + +开发环境已配置 API 代理,所有 `/api/*` 请求会被代理到 `http://localhost:8080`(Go 后端服务)。 + +配置位置:`vite.config.ts` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a8d84fa --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Cluster + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bce0698 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "cluster-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.13.0", + "@emotion/styled": "^11.13.0", + "@mui/icons-material": "^5.16.7", + "@mui/material": "^5.16.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0", + "zustand": "^4.5.2" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.13.1", + "@typescript-eslint/parser": "^7.13.1", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.7", + "typescript": "^5.9.3", + "vite": "^5.4.21" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..79b3dcb --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,2691 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@emotion/react': + specifier: ^11.13.0 + version: 11.14.0(@types/react@18.3.26)(react@18.3.1) + '@emotion/styled': + specifier: ^11.13.0 + version: 11.14.1(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1) + '@mui/icons-material': + specifier: ^5.16.7 + version: 5.18.0(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.26)(react@18.3.1) + '@mui/material': + specifier: ^5.16.7 + version: 5.18.0(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.26.0 + version: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + zustand: + specifier: ^4.5.2 + version: 4.5.7(@types/react@18.3.26)(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.3 + version: 18.3.26 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.26) + '@typescript-eslint/eslint-plugin': + specifier: ^7.13.1 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^7.13.1 + version: 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@vitejs/plugin-react': + specifier: ^4.3.1 + version: 4.7.0(vite@5.4.21) + eslint: + specifier: ^8.57.0 + version: 8.57.1 + eslint-plugin-react-hooks: + specifier: ^4.6.2 + version: 4.6.2(eslint@8.57.1) + eslint-plugin-react-refresh: + specifier: ^0.4.7 + version: 0.4.24(eslint@8.57.1) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^5.4.21 + version: 5.4.21 + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/styled@11.14.1': + resolution: {integrity: sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mui/core-downloads-tracker@5.18.0': + resolution: {integrity: sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==} + + '@mui/icons-material@5.18.0': + resolution: {integrity: sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@mui/material': ^5.0.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/material@5.18.0': + resolution: {integrity: sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + + '@mui/private-theming@5.17.1': + resolution: {integrity: sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/styled-engine@5.18.0': + resolution: {integrity: sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.4.1 + '@emotion/styled': ^11.3.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + + '@mui/system@5.18.0': + resolution: {integrity: sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + + '@mui/types@7.2.24': + resolution: {integrity: sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/utils@5.17.1': + resolution: {integrity: sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + + '@remix-run/router@1.23.0': + resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==} + engines: {node: '>=14.0.0'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.53.1': + resolution: {integrity: sha512-bxZtughE4VNVJlL1RdoSE545kc4JxL7op57KKoi59/gwuU5rV6jLWFXXc8jwgFoT6vtj+ZjO+Z2C5nrY0Cl6wA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.53.1': + resolution: {integrity: sha512-44a1hreb02cAAfAKmZfXVercPFaDjqXCK+iKeVOlJ9ltvnO6QqsBHgKVPTu+MJHSLLeMEUbeG2qiDYgbFPU48g==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.53.1': + resolution: {integrity: sha512-usmzIgD0rf1syoOZ2WZvy8YpXK5G1V3btm3QZddoGSa6mOgfXWkkv+642bfUUldomgrbiLQGrPryb7DXLovPWQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.53.1': + resolution: {integrity: sha512-is3r/k4vig2Gt8mKtTlzzyaSQ+hd87kDxiN3uDSDwggJLUV56Umli6OoL+/YZa/KvtdrdyNfMKHzL/P4siOOmg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.53.1': + resolution: {integrity: sha512-QJ1ksgp/bDJkZB4daldVmHaEQkG4r8PUXitCOC2WRmRaSaHx5RwPoI3DHVfXKwDkB+Sk6auFI/+JHacTekPRSw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.53.1': + resolution: {integrity: sha512-J6ma5xgAzvqsnU6a0+jgGX/gvoGokqpkx6zY4cWizRrm0ffhHDpJKQgC8dtDb3+MqfZDIqs64REbfHDMzxLMqQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.53.1': + resolution: {integrity: sha512-JzWRR41o2U3/KMNKRuZNsDUAcAVUYhsPuMlx5RUldw0E4lvSIXFUwejtYz1HJXohUmqs/M6BBJAUBzKXZVddbg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.53.1': + resolution: {integrity: sha512-L8kRIrnfMrEoHLHtHn+4uYA52fiLDEDyezgxZtGUTiII/yb04Krq+vk3P2Try+Vya9LeCE9ZHU8CXD6J9EhzHQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.53.1': + resolution: {integrity: sha512-ysAc0MFRV+WtQ8li8hi3EoFi7us6d1UzaS/+Dp7FYZfg3NdDljGMoVyiIp6Ucz7uhlYDBZ/zt6XI0YEZbUO11Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.53.1': + resolution: {integrity: sha512-UV6l9MJpDbDZZ/fJvqNcvO1PcivGEf1AvKuTcHoLjVZVFeAMygnamCTDikCVMRnA+qJe+B3pSbgX2+lBMqgBhA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.53.1': + resolution: {integrity: sha512-UDUtelEprkA85g95Q+nj3Xf0M4hHa4DiJ+3P3h4BuGliY4NReYYqwlc0Y8ICLjN4+uIgCEvaygYlpf0hUj90Yg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.53.1': + resolution: {integrity: sha512-vrRn+BYhEtNOte/zbc2wAUQReJXxEx2URfTol6OEfY2zFEUK92pkFBSXRylDM7aHi+YqEPJt9/ABYzmcrS4SgQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.53.1': + resolution: {integrity: sha512-gto/1CxHyi4A7YqZZNznQYrVlPSaodOBPKM+6xcDSCMVZN/Fzb4K+AIkNz/1yAYz9h3Ng+e2fY9H6bgawVq17w==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.53.1': + resolution: {integrity: sha512-KZ6Vx7jAw3aLNjFR8eYVcQVdFa/cvBzDNRFM3z7XhNNunWjA03eUrEwJYPk0G8V7Gs08IThFKcAPS4WY/ybIrQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.53.1': + resolution: {integrity: sha512-HvEixy2s/rWNgpwyKpXJcHmE7om1M89hxBTBi9Fs6zVuLU4gOrEMQNbNsN/tBVIMbLyysz/iwNiGtMOpLAOlvA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.53.1': + resolution: {integrity: sha512-E/n8x2MSjAQgjj9IixO4UeEUeqXLtiA7pyoXCFYLuXpBA/t2hnbIdxHfA7kK9BFsYAoNU4st1rHYdldl8dTqGA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.53.1': + resolution: {integrity: sha512-IhJ087PbLOQXCN6Ui/3FUkI9pWNZe/Z7rEIVOzMsOs1/HSAECCvSZ7PkIbkNqL/AZn6WbZvnoVZw/qwqYMo4/w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.53.1': + resolution: {integrity: sha512-0++oPNgLJHBblreu0SFM7b3mAsBJBTY0Ksrmu9N6ZVrPiTkRgda52mWR7TKhHAsUb9noCjFvAw9l6ZO1yzaVbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.53.1': + resolution: {integrity: sha512-VJXivz61c5uVdbmitLkDlbcTk9Or43YC2QVLRkqp86QoeFSqI81bNgjhttqhKNMKnQMWnecOCm7lZz4s+WLGpQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.53.1': + resolution: {integrity: sha512-NmZPVTUOitCXUH6erJDzTQ/jotYw4CnkMDjCYRxNHVD9bNyfrGoIse684F9okwzKCV4AIHRbUkeTBc9F2OOH5Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.53.1': + resolution: {integrity: sha512-2SNj7COIdAf6yliSpLdLG8BEsp5lgzRehgfkP0Av8zKfQFKku6JcvbobvHASPJu4f3BFxej5g+HuQPvqPhHvpQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.53.1': + resolution: {integrity: sha512-rLarc1Ofcs3DHtgSzFO31pZsCh8g05R2azN1q3fF+H423Co87My0R+tazOEvYVKXSLh8C4LerMK41/K7wlklcg==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react-transition-group@4.4.12': + resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} + peerDependencies: + '@types/react': '*' + + '@types/react@18.3.26': + resolution: {integrity: sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==} + + '@typescript-eslint/eslint-plugin@7.18.0': + resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@7.18.0': + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@7.18.0': + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/type-utils@7.18.0': + resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@7.18.0': + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/typescript-estree@7.18.0': + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@7.18.0': + resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + + '@typescript-eslint/visitor-keys@7.18.0': + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.8.25: + resolution: {integrity: sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==} + hasBin: true + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.27.0: + resolution: {integrity: sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001754: + resolution: {integrity: sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + electron-to-chromium@1.5.249: + resolution: {integrity: sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg==} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@4.6.2: + resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + + eslint-plugin-react-refresh@0.4.24: + resolution: {integrity: sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {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. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@19.2.0: + resolution: {integrity: sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@6.30.1: + resolution: {integrity: sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.1: + resolution: {integrity: sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.53.1: + resolution: {integrity: sha512-n2I0V0lN3E9cxxMqBCT3opWOiQBzRN7UG60z/WDKqdX2zHUS/39lezBcsckZFsV6fUTSnfqI7kHf60jDAPGKug==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.1.4: + resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.27.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/runtime@7.28.4': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.27.1 + '@babel/runtime': 7.28.4 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.26 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.1.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@emotion/babel-plugin': 11.13.5 + '@emotion/is-prop-valid': 1.4.0 + '@emotion/react': 11.14.0(@types/react@18.3.26)(react@18.3.1) + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) + '@emotion/utils': 1.4.2 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.26 + transitivePeerDependencies: + - supports-color + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mui/core-downloads-tracker@5.18.0': {} + + '@mui/icons-material@5.18.0(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.26)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@mui/material': 5.18.0(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.26 + + '@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@mui/core-downloads-tracker': 5.18.0 + '@mui/system': 5.18.0(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1) + '@mui/types': 7.2.24(@types/react@18.3.26) + '@mui/utils': 5.17.1(@types/react@18.3.26)(react@18.3.1) + '@popperjs/core': 2.11.8 + '@types/react-transition-group': 4.4.12(@types/react@18.3.26) + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 19.2.0 + react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@18.3.26)(react@18.3.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1) + '@types/react': 18.3.26 + + '@mui/private-theming@5.17.1(@types/react@18.3.26)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@mui/utils': 5.17.1(@types/react@18.3.26)(react@18.3.1) + prop-types: 15.8.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.26 + + '@mui/styled-engine@5.18.0(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 18.3.1 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@18.3.26)(react@18.3.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1) + + '@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@mui/private-theming': 5.17.1(@types/react@18.3.26)(react@18.3.1) + '@mui/styled-engine': 5.18.0(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1))(react@18.3.1) + '@mui/types': 7.2.24(@types/react@18.3.26) + '@mui/utils': 5.17.1(@types/react@18.3.26)(react@18.3.1) + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 18.3.1 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@18.3.26)(react@18.3.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1))(@types/react@18.3.26)(react@18.3.1) + '@types/react': 18.3.26 + + '@mui/types@7.2.24(@types/react@18.3.26)': + optionalDependencies: + '@types/react': 18.3.26 + + '@mui/utils@5.17.1(@types/react@18.3.26)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@mui/types': 7.2.24(@types/react@18.3.26) + '@types/prop-types': 15.7.15 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.1 + react-is: 19.2.0 + optionalDependencies: + '@types/react': 18.3.26 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@popperjs/core@2.11.8': {} + + '@remix-run/router@1.23.0': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.53.1': + optional: true + + '@rollup/rollup-android-arm64@4.53.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.53.1': + optional: true + + '@rollup/rollup-darwin-x64@4.53.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.53.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.53.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.53.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.53.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.53.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.53.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.53.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.53.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.53.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.53.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.53.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.53.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.53.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.53.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.53.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.53.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.53.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.53.1': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/estree@1.0.8': {} + + '@types/parse-json@4.0.2': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.26)': + dependencies: + '@types/react': 18.3.26 + + '@types/react-transition-group@4.4.12(@types/react@18.3.26)': + dependencies: + '@types/react': 18.3.26 + + '@types/react@18.3.26': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.1.3 + + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 7.18.0 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.3 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + + '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@7.18.0': {} + + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.3 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.3 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + eslint: 8.57.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.3.0': {} + + '@vitejs/plugin-react@4.7.0(vite@5.4.21)': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21 + transitivePeerDependencies: + - supports-color + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.28.4 + cosmiconfig: 7.1.0 + resolve: 1.22.11 + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.8.25: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.27.0: + dependencies: + baseline-browser-mapping: 2.8.25 + caniuse-lite: 1.0.30001754 + electron-to-chromium: 1.5.249 + node-releases: 2.0.27 + update-browserslist-db: 1.1.4(browserslist@4.27.0) + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001754: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@1.9.0: {} + + convert-source-map@2.0.0: {} + + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.28.4 + csstype: 3.1.3 + + electron-to-chromium@1.5.249: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-react-refresh@0.4.24(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@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.3.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + 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.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + 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.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-root@1.1.0: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-arrayish@0.2.1: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.27: {} + + object-assign@4.1.1: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@16.13.1: {} + + react-is@19.2.0: {} + + react-refresh@0.17.0: {} + + react-router-dom@6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.1(react@18.3.1) + + react-router@6.30.1(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.0 + react: 18.3.1 + + react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.4 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + resolve-from@4.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@4.53.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.53.1 + '@rollup/rollup-android-arm64': 4.53.1 + '@rollup/rollup-darwin-arm64': 4.53.1 + '@rollup/rollup-darwin-x64': 4.53.1 + '@rollup/rollup-freebsd-arm64': 4.53.1 + '@rollup/rollup-freebsd-x64': 4.53.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.1 + '@rollup/rollup-linux-arm-musleabihf': 4.53.1 + '@rollup/rollup-linux-arm64-gnu': 4.53.1 + '@rollup/rollup-linux-arm64-musl': 4.53.1 + '@rollup/rollup-linux-loong64-gnu': 4.53.1 + '@rollup/rollup-linux-ppc64-gnu': 4.53.1 + '@rollup/rollup-linux-riscv64-gnu': 4.53.1 + '@rollup/rollup-linux-riscv64-musl': 4.53.1 + '@rollup/rollup-linux-s390x-gnu': 4.53.1 + '@rollup/rollup-linux-x64-gnu': 4.53.1 + '@rollup/rollup-linux-x64-musl': 4.53.1 + '@rollup/rollup-openharmony-arm64': 4.53.1 + '@rollup/rollup-win32-arm64-msvc': 4.53.1 + '@rollup/rollup-win32-ia32-msvc': 4.53.1 + '@rollup/rollup-win32-x64-gnu': 4.53.1 + '@rollup/rollup-win32-x64-msvc': 4.53.1 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + semver@7.7.3: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + slash@3.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.5.7: {} + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@3.1.1: {} + + stylis@4.2.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + text-table@0.2.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@1.4.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + typescript@5.9.3: {} + + update-browserslist-db@1.1.4(browserslist@4.27.0): + dependencies: + browserslist: 4.27.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.53.1 + optionalDependencies: + fsevents: 2.3.3 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + yallist@3.1.1: {} + + yaml@1.10.2: {} + + yocto-queue@0.1.0: {} + + zustand@4.5.7(@types/react@18.3.26)(react@18.3.1): + dependencies: + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.26 + react: 18.3.1 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..b4b1a3c --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,55 @@ +import { Container, Typography, Box, AppBar, Toolbar, Button, Stack } from '@mui/material' +import { Routes, Route, Link } from 'react-router-dom' +import { useAppStore } from './stores/appStore' +import RegistryImageList from './pages/RegistryImageList' + +function App() { + const { count, increment, decrement, reset } = useAppStore() + + return ( + + + + + Cluster + + + + + + + + } /> + + + 欢迎使用 Cluster + + + + Zustand 状态管理示例 + + + + + {count} + + + + + + + } /> + + + + ) +} + +export default App diff --git a/frontend/src/components/.gitkeep b/frontend/src/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..5ec1ac2 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,15 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'LXGW WenKai', '霞鹜文楷', Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#root { + width: 100%; + min-height: 100vh; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..1063a17 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { CssBaseline, ThemeProvider } from '@mui/material' +import { BrowserRouter } from 'react-router-dom' +import App from './App.tsx' +import { theme } from './theme' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + , +) diff --git a/frontend/src/pages/RegistryImageList.tsx b/frontend/src/pages/RegistryImageList.tsx new file mode 100644 index 0000000..3afd805 --- /dev/null +++ b/frontend/src/pages/RegistryImageList.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react' +import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, CircularProgress, Alert } from '@mui/material' + +interface RegistryImage { + id: number + name: string + created_at?: string + updated_at?: string +} + +export default function RegistryImageList() { + const [images, setImages] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let abort = false + async function fetchImages() { + setLoading(true) + setError(null) + try { + const res = await fetch('/api/v1/registry/image/list') + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = await res.json() + const list: RegistryImage[] = data.images || [] + if (!abort) setImages(list) + } catch (e: any) { + if (!abort) setError(e.message) + } finally { + if (!abort) setLoading(false) + } + } + fetchImages() + return () => { abort = true } + }, []) + + return ( + + 镜像列表 + {loading && } + {error && 加载失败: {error}} + {!loading && !error && ( + + + + + + ID + 名称 + 创建时间 + 更新时间 + + + + {images.map(img => ( + + {img.id} + {img.name} + {img.created_at?.replace('T', ' ').replace('Z', '')} + {img.updated_at?.replace('T', ' ').replace('Z', '')} + + ))} + {images.length === 0 && ( + + 暂无镜像 + + )} + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/stores/appStore.ts b/frontend/src/stores/appStore.ts new file mode 100644 index 0000000..ce87526 --- /dev/null +++ b/frontend/src/stores/appStore.ts @@ -0,0 +1,15 @@ +import { create } from 'zustand' + +interface AppState { + count: number + increment: () => void + decrement: () => void + reset: () => void +} + +export const useAppStore = create((set) => ({ + count: 0, + increment: () => set((state) => ({ count: state.count + 1 })), + decrement: () => set((state) => ({ count: state.count - 1 })), + reset: () => set({ count: 0 }), +})) diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts new file mode 100644 index 0000000..6e7dc82 --- /dev/null +++ b/frontend/src/theme.ts @@ -0,0 +1,27 @@ +import { createTheme } from '@mui/material/styles' + +export const theme = createTheme({ + palette: { + mode: 'light', + primary: { + main: '#1976d2', + }, + secondary: { + main: '#dc004e', + }, + }, + typography: { + fontFamily: [ + '"LXGW WenKai"', + '"霞鹜文楷"', + 'Inter', + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + 'Roboto', + '"Helvetica Neue"', + 'Arial', + 'sans-serif', + ].join(','), + }, +}) diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..a7fc6fb --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..f3d72c7 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + // Removed rewrite so /api prefix is preserved for backend route /api/v1/... + }, + }, + }, +}) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ea93383 --- /dev/null +++ b/go.mod @@ -0,0 +1,54 @@ +module gitea.loveuer.com/loveuer/cluster + +go 1.25.0 + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/gofiber/fiber/v3 v3.0.0-beta.2 + github.com/google/uuid v1.6.0 + gorm.io/driver/sqlite v1.5.5 + gorm.io/gorm v1.25.9 +) + +require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/gofiber/utils/v2 v2.0.0-rc.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.65.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect + golang.org/x/tools v0.36.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d5fb853 --- /dev/null +++ b/go.sum @@ -0,0 +1,123 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofiber/fiber/v3 v3.0.0-beta.2 h1:mVVgt8PTaHGup3NGl/+7U7nEoZaXJ5OComV4E+HpAao= +github.com/gofiber/fiber/v3 v3.0.0-beta.2/go.mod h1:w7sdfTY0okjZ1oVH6rSOGvuACUIt0By1iK0HKUb3uqM= +github.com/gofiber/utils/v2 v2.0.0-rc.1 h1:b77K5Rk9+Pjdxz4HlwEBnS7u5nikhx7armQB8xPds4s= +github.com/gofiber/utils/v2 v2.0.0-rc.1/go.mod h1:Y1g08g7gvST49bbjHJ1AVqcsmg93912R/tbKWhn6V3E= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/shamaton/msgpack/v2 v2.2.3 h1:uDOHmxQySlvlUYfQwdjxyybAOzjlQsD1Vjy+4jmO9NM= +github.com/shamaton/msgpack/v2 v2.2.3/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= +github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= +gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= +gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8= +gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= diff --git a/handler/blob.go b/handler/blob.go new file mode 100644 index 0000000..1fe375e --- /dev/null +++ b/handler/blob.go @@ -0,0 +1,76 @@ +package handler + +import ( + "strconv" + "strings" + + "gitea.loveuer.com/loveuer/cluster/controller" + "gitea.loveuer.com/loveuer/cluster/internal/registry/storage" + + "github.com/gofiber/fiber/v3" +) + +// GetBlob 获取 blob +func GetBlob(store storage.Storage) fiber.Handler { + return func(c fiber.Ctx) error { + repo := c.Locals("repo_name") + repoStr := "" + if repo != nil { + repoStr = repo.(string) + } + if repoStr == "" { + repoStr = strings.TrimPrefix(c.Params("name"), "/") + } + digest := c.Locals("digest") + digestStr := "" + if digest != nil { + digestStr = digest.(string) + } + + reader, size, err := controller.GetBlob(store, repoStr, digestStr) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "errors": []fiber.Map{ + { + "code": "BLOB_UNKNOWN", + "message": err.Error(), + }, + }, + }) + } + defer reader.Close() + + c.Set("Content-Type", "application/octet-stream") + c.Set("Content-Length", strconv.FormatInt(size, 10)) + c.Set("Docker-Content-Digest", digestStr) + return c.SendStream(reader, int(size)) + } +} + +// HeadBlob 检查 blob 是否存在 +func HeadBlob(store storage.Storage) fiber.Handler { + return func(c fiber.Ctx) error { + repo := c.Locals("repo_name") + repoStr := "" + if repo != nil { + repoStr = repo.(string) + } + if repoStr == "" { + repoStr = strings.TrimPrefix(c.Params("name"), "/") + } + digest := c.Locals("digest") + digestStr := "" + if digest != nil { + digestStr = digest.(string) + } + + exists, size, err := controller.HeadBlob(store, repoStr, digestStr) + if err != nil || !exists { + return c.SendStatus(fiber.StatusNotFound) + } + + c.Set("Content-Length", strconv.FormatInt(size, 10)) + c.Set("Docker-Content-Digest", digestStr) + return c.SendStatus(fiber.StatusOK) + } +} diff --git a/handler/blob_handler.go b/handler/blob_handler.go new file mode 100644 index 0000000..c1093ad --- /dev/null +++ b/handler/blob_handler.go @@ -0,0 +1,37 @@ +package handler + +import ( + "context" + "io" + + "gitea.loveuer.com/loveuer/cluster/internal/model" + "gitea.loveuer.com/loveuer/cluster/internal/registry/storage" +) + +type blobHandlerImpl struct { + store storage.Storage +} + +// NewBlobHandler creates a new blob handler +func NewBlobHandler(store storage.Storage) BlobHandler { + return &blobHandlerImpl{store: store} +} + +func (b *blobHandlerImpl) Get(ctx context.Context, repo string, h model.Hash) (io.ReadCloser, error) { + reader, _, err := b.store.GetBlob(repo, h.String()) + return reader, err +} + +func (b *blobHandlerImpl) Put(ctx context.Context, repo string, h model.Hash, rc io.ReadCloser) error { + defer rc.Close() + return b.store.PutBlob(repo, h.String(), rc) +} + +func (b *blobHandlerImpl) Stat(ctx context.Context, repo string, h model.Hash) (int64, error) { + return b.store.GetBlobSize(repo, h.String()) +} + +func (b *blobHandlerImpl) Delete(ctx context.Context, repo string, h model.Hash) error { + // TODO: implement delete + return nil +} diff --git a/handler/globals.go b/handler/globals.go new file mode 100644 index 0000000..6d85c2a --- /dev/null +++ b/handler/globals.go @@ -0,0 +1,8 @@ +package handler + +// Global handlers +var ( + blobHandler BlobHandler + uploadHandler UploadHandler + m ManifestHandler +) diff --git a/handler/interfaces.go b/handler/interfaces.go new file mode 100644 index 0000000..f2599c0 --- /dev/null +++ b/handler/interfaces.go @@ -0,0 +1,34 @@ +package handler + +import ( + "context" + "io" + + "gitea.loveuer.com/loveuer/cluster/internal/rerr" + "gitea.loveuer.com/loveuer/cluster/internal/model" +) + +// BlobHandler handles blob operations +type BlobHandler interface { + Get(ctx context.Context, repo string, h model.Hash) (io.ReadCloser, error) + Put(ctx context.Context, repo string, h model.Hash, rc io.ReadCloser) error + Stat(ctx context.Context, repo string, h model.Hash) (int64, error) + Delete(ctx context.Context, repo string, h model.Hash) error +} + +// UploadHandler handles upload operations +type UploadHandler interface { + UploadId() string + Write(ctx context.Context, sessionID string, r io.Reader, start, end int) (int, *rerr.RepositoryError) + Done(ctx context.Context, blobHandler BlobHandler, sessionID string, r io.Reader, contentLength int, repo string, h model.Hash) *rerr.RepositoryError +} + +// ManifestHandler handles manifest operations +type ManifestHandler interface { + Get(ctx context.Context, repo, tag string) (io.ReadCloser, string, *rerr.RepositoryError) + Put(ctx context.Context, repo, tag, digest string, mf *model.RepoSimpleManifest) error + Delete(ctx context.Context, repo, tag string) error + Tags(ctx context.Context, repo string, n, last int, prefix string) (*model.Tag, *rerr.RepositoryError) + Catalog(ctx context.Context, n, last int, prefix string) (*model.Catalog, *rerr.RepositoryError) + Referrers(ctx context.Context, repo, target string) (*model.IndexManifest, *rerr.RepositoryError) +} diff --git a/handler/manifest.go b/handler/manifest.go new file mode 100644 index 0000000..9403a99 --- /dev/null +++ b/handler/manifest.go @@ -0,0 +1,153 @@ +package handler + +import ( + "strconv" + "strings" + + "gitea.loveuer.com/loveuer/cluster/controller" + "gitea.loveuer.com/loveuer/cluster/internal/registry/storage" + + "github.com/gofiber/fiber/v3" +) + +// GetManifest 获取 manifest +func GetManifest(store storage.Storage) fiber.Handler { + return func(c fiber.Ctx) error { + repo := c.Locals("repo_name") + repoStr := "" + if repo != nil { + repoStr = repo.(string) + } + if repoStr == "" { + repoStr = strings.TrimPrefix(c.Params("name"), "/") + } + reference := c.Locals("reference") + referenceStr := "" + if reference != nil { + referenceStr = reference.(string) + } + + data, mediaType, digest, err := controller.GetManifest(store, repoStr, referenceStr) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "errors": []fiber.Map{ + { + "code": "MANIFEST_UNKNOWN", + "message": err.Error(), + }, + }, + }) + } + + c.Set("Content-Type", mediaType) + c.Set("Content-Length", strconv.FormatInt(int64(len(data)), 10)) + c.Set("Docker-Content-Digest", digest) + return c.Send(data) + } +} + +// PutManifest 推送 manifest +func PutManifest(store storage.Storage) fiber.Handler { + return func(c fiber.Ctx) error { + repo := c.Locals("repo_name") + repoStr := "" + if repo != nil { + repoStr = repo.(string) + } + if repoStr == "" { + repoStr = c.Params("name") + } + reference := c.Locals("reference") + referenceStr := "" + if reference != nil { + referenceStr = reference.(string) + } + + // 读取请求体 + data := c.Body() + + // 获取 Content-Type + mediaType := c.Get("Content-Type") + if mediaType == "" { + mediaType = "application/vnd.docker.distribution.manifest.v2+json" + } + + digest, err := controller.PutManifest(store, repoStr, referenceStr, data, mediaType) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "errors": []fiber.Map{ + { + "code": "INTERNAL_ERROR", + "message": err.Error(), + }, + }, + }) + } + + // 返回 Location 和 Digest + c.Set("Location", c.Path()) + c.Set("Docker-Content-Digest", digest) + return c.SendStatus(fiber.StatusCreated) + } +} + +// DeleteManifest 删除 manifest +func DeleteManifest(store storage.Storage) fiber.Handler { + return func(c fiber.Ctx) error { + repo := c.Locals("repo_name") + repoStr := "" + if repo != nil { + repoStr = repo.(string) + } + if repoStr == "" { + repoStr = c.Params("name") + } + reference := c.Locals("reference") + referenceStr := "" + if reference != nil { + referenceStr = reference.(string) + } + + if err := controller.DeleteManifest(store, repoStr, referenceStr); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "errors": []fiber.Map{ + { + "code": "MANIFEST_UNKNOWN", + "message": err.Error(), + }, + }, + }) + } + + return c.SendStatus(fiber.StatusAccepted) + } +} + +// HeadManifest 检查 manifest 是否存在 +func HeadManifest(store storage.Storage) fiber.Handler { + return func(c fiber.Ctx) error { + repo := c.Locals("repo_name") + repoStr := "" + if repo != nil { + repoStr = repo.(string) + } + if repoStr == "" { + repoStr = strings.TrimPrefix(c.Params("name"), "/") + } + reference := c.Locals("reference") + referenceStr := "" + if reference != nil { + referenceStr = reference.(string) + } + + exists, data, mediaType, digest, err := controller.HeadManifest(store, repoStr, referenceStr) + if err != nil || !exists { + return c.SendStatus(fiber.StatusNotFound) + } + + c.Set("Content-Type", mediaType) + c.Set("Content-Length", strconv.FormatInt(int64(len(data)), 10)) + c.Set("Docker-Content-Digest", digest) + return c.SendStatus(fiber.StatusOK) + } +} diff --git a/handler/manifest_handler.go b/handler/manifest_handler.go new file mode 100644 index 0000000..98e480b --- /dev/null +++ b/handler/manifest_handler.go @@ -0,0 +1,106 @@ +package handler + +import ( + "bytes" + "context" + "io" + + "gitea.loveuer.com/loveuer/cluster/internal/database" + "gitea.loveuer.com/loveuer/cluster/internal/model" + "gitea.loveuer.com/loveuer/cluster/internal/rerr" + "gitea.loveuer.com/loveuer/cluster/internal/registry/storage" + + "gorm.io/gorm" +) + +type manifestHandlerImpl struct { + store storage.Storage +} + +// NewManifestHandler creates a new manifest handler +func NewManifestHandler(store storage.Storage) ManifestHandler { + return &manifestHandlerImpl{store: store} +} + +func (m *manifestHandlerImpl) Get(ctx context.Context, repo, tag string) (io.ReadCloser, string, *rerr.RepositoryError) { + data, mediaType, err := m.store.GetManifest(repo, tag) + if err != nil { + return nil, "", &rerr.RepositoryError{ + Status: 404, + Code: "MANIFEST_UNKNOWN", + Message: "manifest not found", + } + } + return io.NopCloser(bytes.NewReader(data)), mediaType, nil +} + +func (m *manifestHandlerImpl) Put(ctx context.Context, repo, tag, digest string, mf *model.RepoSimpleManifest) error { + return m.store.PutManifest(repo, tag, mf.Blob, mf.ContentType) +} + +func (m *manifestHandlerImpl) Delete(ctx context.Context, repo, tag string) error { + return m.store.DeleteManifest(repo, tag) +} + +func (m *manifestHandlerImpl) Tags(ctx context.Context, repo string, n, last int, prefix string) (*model.Tag, *rerr.RepositoryError) { + var repository model.RegistryRepository + if err := database.DB.Where("name = ?", repo).First(&repository).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return &model.Tag{Name: repo, Tags: []string{}}, nil + } + return nil, rerr.ErrInternal(err) + } + + var tags []model.RegistryTag + query := database.DB.Where("repository_id = ?", repository.ID) + if prefix != "" { + query = query.Where("name LIKE ?", prefix+"%") + } + if last > 0 { + query = query.Where("id > ?", last) + } + query = query.Order("id ASC").Limit(n) + + if err := query.Find(&tags).Error; err != nil { + return nil, rerr.ErrInternal(err) + } + + tagNames := make([]string, len(tags)) + for i, tag := range tags { + tagNames[i] = tag.Name + } + + return &model.Tag{Name: repo, Tags: tagNames}, nil +} + +func (m *manifestHandlerImpl) Catalog(ctx context.Context, n, last int, prefix string) (*model.Catalog, *rerr.RepositoryError) { + var repositories []model.RegistryRepository + query := database.DB + if prefix != "" { + query = query.Where("name LIKE ?", prefix+"%") + } + if last > 0 { + query = query.Where("id > ?", last) + } + query = query.Order("id ASC").Limit(n) + + if err := query.Find(&repositories).Error; err != nil { + return nil, rerr.ErrInternal(err) + } + + repoNames := make([]string, len(repositories)) + for i, repo := range repositories { + repoNames[i] = repo.Name + } + + return &model.Catalog{Repositories: repoNames}, nil +} + +func (m *manifestHandlerImpl) Referrers(ctx context.Context, repo, target string) (*model.IndexManifest, *rerr.RepositoryError) { + // For now, return an empty index manifest + return &model.IndexManifest{ + SchemaVersion: 2, + MediaType: "application/vnd.oci.image.index.v1+json", + Manifests: []model.IndexManifestEntry{}, + }, nil +} diff --git a/handler/registry.go b/handler/registry.go new file mode 100644 index 0000000..c4c50f1 --- /dev/null +++ b/handler/registry.go @@ -0,0 +1,113 @@ +package handler + +import ( + "log" + "strings" + + "gitea.loveuer.com/loveuer/cluster/controller" + "gitea.loveuer.com/loveuer/cluster/internal/registry/storage" + + "github.com/gofiber/fiber/v3" +) + +func isBlob(c fiber.Ctx) bool { + elem := strings.Split(c.Path(), "/") + elem = elem[1:] + if elem[len(elem)-1] == "" { + elem = elem[:len(elem)-1] + } + if len(elem) < 3 { + return false + } + return elem[len(elem)-2] == "blobs" || (elem[len(elem)-3] == "blobs" && + elem[len(elem)-2] == "uploads") +} + +func isManifest(c fiber.Ctx) bool { + elems := strings.Split(c.Path(), "/") + elems = elems[1:] + if len(elems) < 4 { + return false + } + return elems[len(elems)-2] == "manifests" +} + +func isTags(c fiber.Ctx) bool { + elems := strings.Split(c.Path(), "/") + elems = elems[1:] + if len(elems) < 4 { + return false + } + return elems[len(elems)-2] == "tags" +} + +func isCatalog(c fiber.Ctx) bool { + elems := strings.Split(c.Path(), "/") + elems = elems[1:] + if len(elems) < 2 { + return false + } + return elems[len(elems)-1] == "_catalog" +} + +func isReferrers(c fiber.Ctx) bool { + elems := strings.Split(c.Path(), "/") + elems = elems[1:] + if len(elems) < 4 { + return false + } + return elems[len(elems)-2] == "referrers" +} + +// RegistryV2 handles all OCI Registry API v2 requests +func RegistryV2(store storage.Storage) fiber.Handler { + // Initialize handlers + blobHandler = NewBlobHandler(store) + uploadHandler = NewUploadHandler() + m = NewManifestHandler(store) + + return func(c fiber.Ctx) error { + if isBlob(c) { + return handleBlobs(c) + } + + if isManifest(c) { + return handleManifest(c) + } + + if isTags(c) { + return handleTags(c) + } + + if isCatalog(c) { + return handleCatalog(c) + } + + if isReferrers(c) { + return handleReferrers(c) + } + + // Handle root v2 endpoint + if c.Path() == "/v2/" { + c.Set("Docker-Distribution-API-Version", "registry/2.0") + return c.SendStatus(200) + } + + c.Set("Docker-Distribution-API-Version", "registry/2.0") + + log.Printf("[WARN] RegistryV2: unknown endpoint - path = %s, method = %s", c.Path(), c.Method()) + + return c.Status(404).JSON(fiber.Map{ + "errors": []fiber.Map{{"code": "NOT_FOUND", "message": "endpoint not found"}}, + }) + } +} + +// ListImages 返回所有仓库列表 +func ListImages(c fiber.Ctx) error { + repos, err := controller.ListImages() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"images": repos}) +} diff --git a/handler/registry_blob.go b/handler/registry_blob.go new file mode 100644 index 0000000..9c9c07c --- /dev/null +++ b/handler/registry_blob.go @@ -0,0 +1,288 @@ +package handler + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "os" + "path" + "strings" + + "gitea.loveuer.com/loveuer/cluster/internal/database" + "gitea.loveuer.com/loveuer/cluster/internal/model" + "gitea.loveuer.com/loveuer/cluster/internal/rerr" + + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" +) + +var ErrNotFound = errors.New("not found") + +func handleBlobs(c fiber.Ctx) error { + elem := strings.Split(c.Path(), "/") + elem = elem[1:] + if elem[len(elem)-1] == "" { + elem = elem[:len(elem)-1] + } + + // Must have a path of form /v2/{name}/blobs/{upload,sha256:} + if len(elem) < 4 { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusBadRequest, + Code: "NAME_INVALID", + Message: "blobs must be attached to a repo", + }) + } + + target := elem[len(elem)-1] + service := elem[len(elem)-2] + digest := c.Query("digest") + contentRange := c.Get("Content-Range") + rangeHeader := c.Get("Range") + repo := strings.Join(elem[1:len(elem)-2], "/") + + switch c.Method() { + case http.MethodHead: + h, err := model.NewHash(target) + if err != nil { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusBadRequest, + Code: "NAME_INVALID", + Message: "invalid digest", + }) + } + + size, err := blobHandler.Stat(c.Context(), repo, h) + if errors.Is(err, ErrNotFound) { + return rerr.Error(c, rerr.ErrBlobUnknown) + } else if err != nil { + return rerr.Error(c, rerr.ErrInternal(err)) + } + + c.Set("Content-Length", fmt.Sprint(size)) + c.Set("Docker-Content-Digest", h.String()) + return c.Send(nil) + + case http.MethodGet: + h, err := model.NewHash(target) + if err != nil { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusBadRequest, + Code: "NAME_INVALID", + Message: "invalid digest", + }) + } + + size, err := blobHandler.Stat(c.Context(), repo, h) + if errors.Is(err, ErrNotFound) { + return rerr.Error(c, rerr.ErrBlobUnknown) + } else if err != nil { + return rerr.Error(c, rerr.ErrInternal(err)) + } + + rc, err := blobHandler.Get(c.Context(), repo, h) + if errors.Is(err, ErrNotFound) { + return rerr.Error(c, rerr.ErrBlobUnknown) + } else if err != nil { + return rerr.Error(c, rerr.ErrInternal(err)) + } + defer rc.Close() + + r := rc + if rangeHeader != "" { + start, end := int64(0), int64(0) + if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); err != nil { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusRequestedRangeNotSatisfiable, + Code: "BLOB_UNKNOWN", + Message: "We don't understand your Range", + }) + } + + n := (end + 1) - start + if ra, ok := r.(io.ReaderAt); ok { + if end+1 > size { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusRequestedRangeNotSatisfiable, + Code: "BLOB_UNKNOWN", + Message: fmt.Sprintf("range end %d > %d size", end+1, size), + }) + } + sr := io.NewSectionReader(ra, start, n) + r = io.NopCloser(sr) + } else { + if _, err := io.CopyN(io.Discard, r, start); err != nil { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusRequestedRangeNotSatisfiable, + Code: "BLOB_UNKNOWN", + Message: fmt.Sprintf("Failed to discard %d bytes", start), + }) + } + lr := io.LimitReader(r, n) + r = io.NopCloser(lr) + } + + c.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size)) + c.Set("Content-Length", fmt.Sprint(n)) + c.Set("Docker-Content-Digest", h.String()) + c.Status(http.StatusPartialContent) + } else { + c.Set("Content-Length", fmt.Sprint(size)) + c.Set("Docker-Content-Digest", h.String()) + c.Status(http.StatusOK) + } + + _, err = io.Copy(c, r) + return err + + case http.MethodPost: + if target != "uploads" { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusBadRequest, + Code: "METHOD_UNKNOWN", + Message: fmt.Sprintf("POST to /blobs must be followed by /uploads, got %s", target), + }) + } + + if digest != "" { + h, err := model.NewHash(digest) + if err != nil { + return rerr.Error(c, rerr.ErrDigestInvalid) + } + + vrc := io.NopCloser(bytes.NewReader(c.Body())) + defer vrc.Close() + + if err = blobHandler.Put(c.Context(), repo, h, vrc); err != nil { + return rerr.Error(c, rerr.ErrInternal(err)) + } + c.Set("Docker-Content-Digest", h.String()) + return c.SendStatus(http.StatusCreated) + } + + id := uploadHandler.UploadId() + + // Get or create repository + var repository model.RegistryRepository + if err := database.DB.Where("name = ?", repo).First(&repository).Error; err != nil { + if err == gorm.ErrRecordNotFound { + repository = model.RegistryRepository{Name: repo} + if err := database.DB.Create(&repository).Error; err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if err := database.DB.Where("name = ?", repo).First(&repository).Error; err != nil { + return rerr.Error(c, rerr.ErrInternal(err)) + } + } else { + return rerr.Error(c, rerr.ErrInternal(err)) + } + } + } else { + return rerr.Error(c, rerr.ErrInternal(err)) + } + } + + // Create upload session + uploadPath := model.GetUploadPath("./x-storage", id) + if err := os.MkdirAll(path.Dir(uploadPath), 0755); err != nil { + return rerr.Error(c, rerr.ErrInternal(err)) + } + + session := model.RegistryUploadSession{ + RepositoryID: repository.ID, + SessionID: id, + Path: uploadPath, + Size: 0, + } + if err := database.DB.Create(&session).Error; err != nil { + return rerr.Error(c, rerr.ErrInternal(err)) + } + + c.Set("Location", "/"+path.Join("v2", path.Join(elem[1:len(elem)-2]...), "blobs/uploads", id)) + c.Set("Range", "0-0") + + return c.SendStatus(http.StatusAccepted) + + case http.MethodPatch: + if service != "uploads" { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusBadRequest, + Code: "METHOD_UNKNOWN", + Message: fmt.Sprintf("PATCH to /blobs must be followed by /uploads, got %s", service), + }) + } + + start, end := 0, 0 + if contentRange != "" { + if _, err := fmt.Sscanf(contentRange, "%d-%d", &start, &end); err != nil { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusRequestedRangeNotSatisfiable, + Code: "BLOB_UPLOAD_UNKNOWN", + Message: "We don't understand your Content-Range", + }) + } + + expectedEnd := start + len(c.Body()) - 1 + if end != expectedEnd { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusRequestedRangeNotSatisfiable, + Code: "BLOB_UPLOAD_INVALID", + Message: fmt.Sprintf("blob upload content range mismatch: expected end %d, got %d", expectedEnd, end), + }) + } + } else { + end = start + len(c.Body()) - 1 + } + + length, re := uploadHandler.Write(c.Context(), target, bytes.NewReader(c.Body()), start, end) + if re != nil { + return rerr.Error(c, re) + } + + c.Set("Location", "/"+path.Join("v2", path.Join(elem[1:len(elem)-3]...), "blobs/uploads", target)) + c.Set("Range", fmt.Sprintf("0-%d", length-1)) + return c.SendStatus(http.StatusNoContent) + + case http.MethodPut: + if service != "uploads" { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusBadRequest, + Code: "METHOD_UNKNOWN", + Message: fmt.Sprintf("PUT to /blobs must be followed by /uploads, got %s", service), + }) + } + + if digest == "" { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusBadRequest, + Code: "DIGEST_INVALID", + Message: "digest not specified", + }) + } + + hash, err := model.NewHash(digest) + if err != nil { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusBadRequest, + Code: "NAME_INVALID", + Message: "invalid digest", + }) + } + + re := uploadHandler.Done(c.Context(), blobHandler, target, bytes.NewReader(c.Body()), len(c.Body()), repo, hash) + if re != nil { + return rerr.Error(c, re) + } + + c.Set("Docker-Content-Digest", hash.String()) + return c.SendStatus(http.StatusCreated) + + default: + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusBadRequest, + Code: "METHOD_UNKNOWN", + Message: "We don't understand your method + url", + }) + } +} diff --git a/handler/registry_catalog.go b/handler/registry_catalog.go new file mode 100644 index 0000000..98f3d1d --- /dev/null +++ b/handler/registry_catalog.go @@ -0,0 +1,33 @@ +package handler + +import ( + "net/http" + "strconv" + + "gitea.loveuer.com/loveuer/cluster/internal/rerr" + + "github.com/gofiber/fiber/v3" +) + +func handleCatalog(c fiber.Ctx) error { + if c.Method() != "GET" { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusBadRequest, + Code: "METHOD_UNKNOWN", + Message: "We don't understand your method + url", + }) + } + + nStr := c.Query("n") + n := 10000 + if nStr != "" { + n, _ = strconv.Atoi(nStr) + } + + list, re := m.Catalog(c.Context(), n, 0, "") + if re != nil { + return rerr.Error(c, re) + } + + return c.JSON(list) +} diff --git a/handler/registry_manifest.go b/handler/registry_manifest.go new file mode 100644 index 0000000..3635cc3 --- /dev/null +++ b/handler/registry_manifest.go @@ -0,0 +1,92 @@ +package handler + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + + "gitea.loveuer.com/loveuer/cluster/internal/model" + "gitea.loveuer.com/loveuer/cluster/internal/rerr" + + "github.com/gofiber/fiber/v3" +) + +func handleManifest(c fiber.Ctx) error { + elem := strings.Split(c.Path(), "/") + elem = elem[1:] + target := elem[len(elem)-1] + repo := strings.Join(elem[1:len(elem)-2], "/") + + switch c.Method() { + case http.MethodGet: + reader, contentType, re := m.Get(c.Context(), repo, target) + if re != nil { + return rerr.Error(c, re) + } + + bs, err := io.ReadAll(reader) + if err != nil { + return rerr.Error(c, rerr.ErrInternal(err)) + } + + h, _, _ := model.SHA256(bytes.NewReader(bs)) + c.Set("Docker-Content-Digest", h.String()) + c.Set("Content-Type", contentType) + c.Set("Content-Length", fmt.Sprint(len(bs))) + return c.Send(bs) + + case http.MethodHead: + reader, contentType, re := m.Get(c.Context(), repo, target) + if re != nil { + return rerr.Error(c, re) + } + + bs, err := io.ReadAll(reader) + if err != nil { + return rerr.Error(c, rerr.ErrInternal(err)) + } + + h, _, _ := model.SHA256(bytes.NewReader(bs)) + c.Set("Docker-Content-Digest", h.String()) + c.Set("Content-Type", contentType) + c.Set("Content-Length", fmt.Sprint(len(bs))) + return c.Send(bs) + + case http.MethodPut: + buf := &bytes.Buffer{} + if _, err := io.Copy(buf, bytes.NewReader(c.Body())); err != nil { + return rerr.Error(c, rerr.ErrInternal(err)) + } + + hash, _, err := model.SHA256(bytes.NewReader(buf.Bytes())) + if err != nil { + return rerr.Error(c, rerr.ErrInternal(err)) + } + + digest := hash.String() + + mf := model.RepoSimpleManifest{ + Blob: buf.Bytes(), + ContentType: c.Get("Content-Type"), + } + + if err := m.Put(c.Context(), repo, target, digest, &mf); err != nil { + return rerr.Error(c, rerr.ErrInternal(err)) + } + + c.Set("Docker-Content-Digest", digest) + return c.SendStatus(http.StatusCreated) + + case http.MethodDelete: + return c.SendStatus(http.StatusAccepted) + + default: + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusBadRequest, + Code: "METHOD_UNKNOWN", + Message: "We don't understand your method + url", + }) + } +} diff --git a/handler/registry_referrers.go b/handler/registry_referrers.go new file mode 100644 index 0000000..83688ee --- /dev/null +++ b/handler/registry_referrers.go @@ -0,0 +1,32 @@ +package handler + +import ( + "net/http" + "strings" + + "gitea.loveuer.com/loveuer/cluster/internal/rerr" + + "github.com/gofiber/fiber/v3" +) + +func handleReferrers(c fiber.Ctx) error { + if c.Method() != "GET" { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusBadRequest, + Code: "METHOD_UNKNOWN", + Message: "We don't understand your method + url", + }) + } + + elem := strings.Split(c.Path(), "/") + elem = elem[1:] + repo := strings.Join(elem[1:len(elem)-2], "/") + target := elem[len(elem)-1] + + index, re := m.Referrers(c.Context(), repo, target) + if re != nil { + return rerr.Error(c, re) + } + + return c.JSON(index) +} diff --git a/handler/registry_tag.go b/handler/registry_tag.go new file mode 100644 index 0000000..215f066 --- /dev/null +++ b/handler/registry_tag.go @@ -0,0 +1,49 @@ +package handler + +import ( + "net/http" + "strings" + + "gitea.loveuer.com/loveuer/cluster/internal/rerr" + + "github.com/gofiber/fiber/v3" +) + +func handleTags(c fiber.Ctx) error { + if c.Method() != "GET" { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusBadRequest, + Code: "METHOD_UNKNOWN", + Message: "We don't understand your method + url", + }) + } + + type Req struct { + Last int `json:"last" query:"last"` + N int `json:"n" query:"n"` + } + + elem := strings.Split(c.Path(), "/") + elem = elem[1:] + repo := strings.Join(elem[1:len(elem)-2], "/") + + var req Req + if err := c.Bind().Query(&req); err != nil { + return rerr.Error(c, &rerr.RepositoryError{ + Status: http.StatusBadRequest, + Code: "BAD_REQUEST", + Message: err.Error(), + }) + } + + if req.N <= 0 { + req.N = 100 + } + + list, re := m.Tags(c.Context(), repo, req.N, req.Last, "") + if re != nil { + return rerr.Error(c, re) + } + + return c.JSON(list) +} diff --git a/handler/upload.go b/handler/upload.go new file mode 100644 index 0000000..47f605d --- /dev/null +++ b/handler/upload.go @@ -0,0 +1,173 @@ +package handler + +import ( + "bytes" + "strconv" + "strings" + + "gitea.loveuer.com/loveuer/cluster/controller" + "gitea.loveuer.com/loveuer/cluster/internal/registry/storage" + + "github.com/gofiber/fiber/v3" +) + +// StartBlobUpload 开始 blob 上传 +func StartBlobUpload(store storage.Storage) fiber.Handler { + return func(c fiber.Ctx) error { + repo := c.Locals("repo_name") + repoStr := "" + if repo != nil { + repoStr = repo.(string) + } + if repoStr == "" { + repoStr = strings.TrimPrefix(c.Params("name"), "/") + } + + uuid, err := controller.StartBlobUpload(store, repoStr) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "errors": []fiber.Map{ + { + "code": "INTERNAL_ERROR", + "message": err.Error(), + }, + }, + }) + } + + // 返回上传 URL + location := c.Path() + "/" + uuid + c.Set("Location", location) + c.Set("Docker-Upload-UUID", uuid) + c.Set("Range", "0-0") + return c.SendStatus(fiber.StatusAccepted) + } +} + +// PatchBlobUpload 上传 blob 数据块 +func PatchBlobUpload(store storage.Storage) fiber.Handler { + return func(c fiber.Ctx) error { + uuid := c.Locals("uuid") + uuidStr := "" + if uuid != nil { + uuidStr = uuid.(string) + } + + // 获取 Range header + rangeHeader := c.Get("Content-Range") + if rangeHeader == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "errors": []fiber.Map{ + { + "code": "BAD_REQUEST", + "message": "Content-Range header required", + }, + }, + }) + } + + // 读取数据 + data := bytes.NewReader(c.Body()) + size, err := controller.PatchBlobUpload(store, uuidStr, data) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "errors": []fiber.Map{ + { + "code": "INTERNAL_ERROR", + "message": err.Error(), + }, + }, + }) + } + + location := c.Path() + c.Set("Location", location) + c.Set("Docker-Upload-UUID", uuidStr) + c.Set("Range", "0-"+strconv.FormatInt(size-1, 10)) + return c.SendStatus(fiber.StatusNoContent) + } +} + +// PutBlobUpload 完成 blob 上传 +func PutBlobUpload(store storage.Storage) fiber.Handler { + return func(c fiber.Ctx) error { + repo := c.Locals("repo_name") + repoStr := "" + if repo != nil { + repoStr = repo.(string) + } + if repoStr == "" { + repoStr = strings.TrimPrefix(c.Params("name"), "/") + } + uuid := c.Locals("uuid") + uuidStr := "" + if uuid != nil { + uuidStr = uuid.(string) + } + + // 获取 digest + digest := c.Query("digest") + if digest == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "errors": []fiber.Map{ + { + "code": "BAD_REQUEST", + "message": "digest query parameter required", + }, + }, + }) + } + + // 如果有请求体,先追加数据 + var data interface{} = nil + if len(c.Body()) > 0 { + data = bytes.NewReader(c.Body()) + } + + location, err := controller.PutBlobUpload(store, repoStr, uuidStr, digest, data, c.Path()) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "errors": []fiber.Map{ + { + "code": "BAD_REQUEST", + "message": err.Error(), + }, + }, + }) + } + + c.Set("Location", location) + c.Set("Content-Length", "0") + c.Set("Docker-Content-Digest", digest) + return c.SendStatus(fiber.StatusCreated) + } +} + +// GetBlobUpload 获取上传状态 +func GetBlobUpload(store storage.Storage) fiber.Handler { + return func(c fiber.Ctx) error { + uuid := c.Locals("uuid") + uuidStr := "" + if uuid != nil { + uuidStr = uuid.(string) + } + + size, err := controller.GetBlobUpload(store, uuidStr) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "errors": []fiber.Map{ + { + "code": "UPLOAD_UNKNOWN", + "message": err.Error(), + }, + }, + }) + } + + location := c.Path() + c.Set("Location", location) + c.Set("Docker-Upload-UUID", uuidStr) + c.Set("Range", "0-"+strconv.FormatInt(size-1, 10)) + return c.SendStatus(fiber.StatusNoContent) + } +} diff --git a/handler/upload_handler.go b/handler/upload_handler.go new file mode 100644 index 0000000..7ea426c --- /dev/null +++ b/handler/upload_handler.go @@ -0,0 +1,99 @@ +package handler + +import ( + "context" + "io" + "os" + + "gitea.loveuer.com/loveuer/cluster/internal/database" + "gitea.loveuer.com/loveuer/cluster/internal/model" + "gitea.loveuer.com/loveuer/cluster/internal/rerr" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type uploadHandlerImpl struct{} + +// NewUploadHandler creates a new upload handler +func NewUploadHandler() UploadHandler { + return &uploadHandlerImpl{} +} + +func (u *uploadHandlerImpl) UploadId() string { + return uuid.New().String() +} + +func (u *uploadHandlerImpl) Write(ctx context.Context, sessionID string, r io.Reader, start, end int) (int, *rerr.RepositoryError) { + // Get upload session + var session model.RegistryUploadSession + if err := database.DB.Where("session_id = ?", sessionID).First(&session).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return 0, &rerr.RepositoryError{ + Status: 404, + Code: "BLOB_UPLOAD_UNKNOWN", + Message: "upload session not found", + } + } + return 0, rerr.ErrInternal(err) + } + + // Open file for writing + file, err := os.OpenFile(session.Path, os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return 0, rerr.ErrInternal(err) + } + defer file.Close() + + // Seek to start position + if _, err := file.Seek(int64(start), 0); err != nil { + return 0, rerr.ErrInternal(err) + } + + // Write data + n, err := io.CopyN(file, r, int64(end-start+1)) + if err != nil && err != io.EOF { + return 0, rerr.ErrInternal(err) + } + + // Update session size + session.Size = int64(start) + n + if err := database.DB.Save(&session).Error; err != nil { + return 0, rerr.ErrInternal(err) + } + + return int(n), nil +} + +func (u *uploadHandlerImpl) Done(ctx context.Context, blobHandler BlobHandler, sessionID string, r io.Reader, contentLength int, repo string, h model.Hash) *rerr.RepositoryError { + // Get upload session + var session model.RegistryUploadSession + if err := database.DB.Where("session_id = ?", sessionID).First(&session).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return &rerr.RepositoryError{ + Status: 404, + Code: "BLOB_UPLOAD_UNKNOWN", + Message: "upload session not found", + } + } + return rerr.ErrInternal(err) + } + + // Read from the uploaded file instead of the request body + file, err := os.Open(session.Path) + if err != nil { + return rerr.ErrInternal(err) + } + defer file.Close() + + // Store blob + if err := blobHandler.Put(ctx, repo, h, file); err != nil { + return rerr.ErrInternal(err) + } + + // Clean up upload session + os.Remove(session.Path) + database.DB.Delete(&session) + + return nil +} diff --git a/handler/version.go b/handler/version.go new file mode 100644 index 0000000..dc0822b --- /dev/null +++ b/handler/version.go @@ -0,0 +1,11 @@ +package handler + +import ( + "github.com/gofiber/fiber/v3" +) + +// VersionCheck API 版本检查 +func VersionCheck(c fiber.Ctx) error { + c.Set("Docker-Distribution-API-Version", "registry/2.0") + return c.SendStatus(fiber.StatusOK) +} diff --git a/internal/api/v1/registry/images.go b/internal/api/v1/registry/images.go new file mode 100644 index 0000000..dd50190 --- /dev/null +++ b/internal/api/v1/registry/images.go @@ -0,0 +1,19 @@ +package registry + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "gitea.loveuer.com/loveuer/cluster/internal/database" +) + +// ListImages returns all repositories as images +func ListImages(c *gin.Context) { + var repos []database.Repository + if err := database.DB.Find(&repos).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"images": repos}) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2326fcb --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,56 @@ +package config + +import ( + "fmt" + "os" +) + +type Config struct { + Server ServerConfig `json:"server"` + Storage StorageConfig `json:"storage"` +} + +type ServerConfig struct { + Address string `json:"address"` // 监听地址,如 :8080 + Debug bool `json:"debug"` // 是否开启调试模式 +} + +type StorageConfig struct { + RootPath string `json:"root_path"` // 数据存储目录 +} + +// LoadFromFlags 从命令行参数加载配置 +func LoadFromFlags(debug bool, address, dataDir string) (*Config, error) { + cfg := &Config{ + Server: ServerConfig{ + Address: address, + Debug: debug, + }, + Storage: StorageConfig{ + RootPath: dataDir, + }, + } + + // 确保存储目录存在 + if err := os.MkdirAll(cfg.Storage.RootPath, 0755); err != nil { + return nil, fmt.Errorf("failed to create data directory %s: %w", cfg.Storage.RootPath, err) + } + + return cfg, nil +} + +// Load 从环境变量加载配置(保留向后兼容) +func Load() (*Config, error) { + address := getEnv("ADDRESS", ":8080") + dataDir := getEnv("DATA_DIR", "./storage") + debug := getEnv("DEBUG", "false") == "true" + + return LoadFromFlags(debug, address, dataDir) +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..5142ff7 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,114 @@ +package database + +import ( + "fmt" + "log" + "os" + "path/filepath" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// DB 数据库连接 +var DB *gorm.DB + +// Init 初始化数据库 +func Init(dataDir string) error { + // 确保数据目录存在 + if err := os.MkdirAll(dataDir, 0755); err != nil { + return fmt.Errorf("failed to create data directory %s: %w", dataDir, err) + } + + dbPath := filepath.Join(dataDir, "cluster.db") + + // 检查数据库文件是否存在 + dbExists := false + if _, err := os.Stat(dbPath); err == nil { + dbExists = true + log.Printf("Database file already exists: %s", dbPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to check database file: %w", err) + } else { + log.Printf("Creating new database file: %s", dbPath) + } + + // 配置 GORM logger + gormLogger := logger.Default + if os.Getenv("GORM_LOG_LEVEL") == "silent" { + gormLogger = gormLogger.LogMode(logger.Silent) + } + + // 打开数据库连接 + var err error + DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{ + Logger: gormLogger, + }) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + + // 获取底层 sql.DB 以设置连接池 + sqlDB, err := DB.DB() + if err != nil { + return fmt.Errorf("failed to get database instance: %w", err) + } + + // 设置连接池参数 + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + sqlDB.SetConnMaxLifetime(time.Hour) + + // 启用外键约束 + if err := DB.Exec("PRAGMA foreign_keys = ON").Error; err != nil { + return fmt.Errorf("failed to enable foreign keys: %w", err) + } + + // 自动迁移表结构 + if err := autoMigrate(); err != nil { + return fmt.Errorf("failed to migrate tables: %w", err) + } + + if !dbExists { + log.Printf("Database initialized successfully: %s", dbPath) + } else { + log.Printf("Database tables verified: %s", dbPath) + } + + return nil +} + +// Close 关闭数据库连接 +func Close() error { + if DB != nil { + sqlDB, err := DB.DB() + if err != nil { + return err + } + return sqlDB.Close() + } + return nil +} + +// autoMigrate 自动迁移表结构 +func autoMigrate() error { + models := []interface{}{ + &Repository{}, + &Manifest{}, + &Blob{}, + &Tag{}, + &BlobUpload{}, + &ManifestBlob{}, + } + + for _, model := range models { + if err := DB.AutoMigrate(model); err != nil { + return fmt.Errorf("failed to migrate model %T: %w", model, err) + } + } + + log.Printf("Database migration completed successfully") + return nil +} diff --git a/internal/database/models.go b/internal/database/models.go new file mode 100644 index 0000000..b24ea2e --- /dev/null +++ b/internal/database/models.go @@ -0,0 +1,116 @@ +package database + +import "time" + +// Repository 仓库模型 +type Repository struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"uniqueIndex;not null" json:"name"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + // 关联关系 + Manifests []Manifest `gorm:"foreignKey:RepositoryID;constraint:OnDelete:CASCADE" json:"manifests,omitempty"` + Tags []Tag `gorm:"foreignKey:RepositoryID;constraint:OnDelete:CASCADE" json:"tags,omitempty"` + BlobUploads []BlobUpload `gorm:"foreignKey:RepositoryID;constraint:OnDelete:CASCADE" json:"blob_uploads,omitempty"` +} + +// TableName 指定表名 +func (Repository) TableName() string { + return "repositories" +} + +// Manifest Manifest 模型 +type Manifest struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + RepositoryID uint `gorm:"index;not null" json:"repository_id"` + Digest string `gorm:"index;not null" json:"digest"` + MediaType string `gorm:"not null" json:"media_type"` + Size int64 `gorm:"not null" json:"size"` + DataPath string `gorm:"not null" json:"data_path"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + + // 唯一约束:同一仓库中 digest 唯一 + _ struct{} `gorm:"uniqueIndex:idx_repo_digest"` + + // 关联关系 + Repository Repository `gorm:"foreignKey:RepositoryID" json:"repository,omitempty"` + Tags []Tag `gorm:"foreignKey:ManifestID;constraint:OnDelete:CASCADE" json:"tags,omitempty"` + ManifestBlobs []ManifestBlob `gorm:"foreignKey:ManifestID;constraint:OnDelete:CASCADE" json:"manifest_blobs,omitempty"` +} + +// TableName 指定表名 +func (Manifest) TableName() string { + return "manifests" +} + +// Blob Blob 模型 +type Blob struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Digest string `gorm:"uniqueIndex;not null" json:"digest"` + Size int64 `gorm:"not null" json:"size"` + DataPath string `gorm:"not null" json:"data_path"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + + // 关联关系 + ManifestBlobs []ManifestBlob `gorm:"foreignKey:BlobID;constraint:OnDelete:CASCADE" json:"manifest_blobs,omitempty"` +} + +// TableName 指定表名 +func (Blob) TableName() string { + return "blobs" +} + +// Tag 标签模型 +type Tag struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + RepositoryID uint `gorm:"uniqueIndex:idx_repo_name;not null" json:"repository_id"` + Name string `gorm:"uniqueIndex:idx_repo_name;not null" json:"name"` + ManifestID uint `gorm:"index;not null" json:"manifest_id"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + // 关联关系 + Repository Repository `gorm:"foreignKey:RepositoryID" json:"repository,omitempty"` + Manifest Manifest `gorm:"foreignKey:ManifestID" json:"manifest,omitempty"` +} + +// TableName 指定表名 +func (Tag) TableName() string { + return "tags" +} + +// BlobUpload Blob 上传会话模型 +type BlobUpload struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + UUID string `gorm:"uniqueIndex;not null" json:"uuid"` + RepositoryID uint `gorm:"index;not null" json:"repository_id"` + Size int64 `gorm:"default:0" json:"size"` + DataPath string `gorm:"not null" json:"data_path"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + // 关联关系 + Repository Repository `gorm:"foreignKey:RepositoryID" json:"repository,omitempty"` +} + +// TableName 指定表名 +func (BlobUpload) TableName() string { + return "blob_uploads" +} + +// ManifestBlob Manifest 和 Blob 关联模型 +type ManifestBlob struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + ManifestID uint `gorm:"uniqueIndex:idx_manifest_blob;index;not null" json:"manifest_id"` + BlobID uint `gorm:"uniqueIndex:idx_manifest_blob;index;not null" json:"blob_id"` + + // 关联关系 + Manifest Manifest `gorm:"foreignKey:ManifestID" json:"manifest,omitempty"` + Blob Blob `gorm:"foreignKey:BlobID" json:"blob,omitempty"` +} + +// TableName 指定表名 +func (ManifestBlob) TableName() string { + return "manifest_blobs" +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..c44ba35 --- /dev/null +++ b/internal/middleware/cors.go @@ -0,0 +1,21 @@ +package middleware + +import ( + "github.com/gofiber/fiber/v3" +) + +// CORS 跨域中间件 +func CORS() fiber.Handler { + return func(c fiber.Ctx) error { + c.Set("Access-Control-Allow-Origin", "*") + c.Set("Access-Control-Allow-Credentials", "true") + c.Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH, HEAD") + + if c.Method() == "OPTIONS" { + return c.SendStatus(fiber.StatusNoContent) + } + + return c.Next() + } +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 0000000..1b6c68a --- /dev/null +++ b/internal/middleware/logger.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "fmt" + "time" + + "github.com/gofiber/fiber/v3" +) + +// Logger 日志中间件 +func Logger() fiber.Handler { + return func(c fiber.Ctx) error { + start := time.Now() + err := c.Next() + latency := time.Since(start) + + fmt.Printf("%s - [%s] \"%s %s %s %d %s \"%s\" %s\"\n", + c.IP(), + time.Now().Format(time.RFC1123), + c.Method(), + c.Path(), + c.Protocol(), + c.Response().StatusCode(), + latency, + c.Get("User-Agent"), + "", + ) + + return err + } +} diff --git a/internal/middleware/recovery.go b/internal/middleware/recovery.go new file mode 100644 index 0000000..b95b29c --- /dev/null +++ b/internal/middleware/recovery.go @@ -0,0 +1,24 @@ +package middleware + +import ( + "github.com/gofiber/fiber/v3" +) + +// Recovery 恢复中间件 +func Recovery() fiber.Handler { + return func(c fiber.Ctx) error { + defer func() { + if r := recover(); r != nil { + c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "errors": []fiber.Map{ + { + "code": "INTERNAL_ERROR", + "message": "Internal server error", + }, + }, + }) + } + }() + return c.Next() + } +} diff --git a/internal/middleware/repo.go b/internal/middleware/repo.go new file mode 100644 index 0000000..e902465 --- /dev/null +++ b/internal/middleware/repo.go @@ -0,0 +1,13 @@ +package middleware + +import ( + "github.com/gofiber/fiber/v3" +) + +// RepoMiddleware 仓库名中间件(如果需要的话) +func RepoMiddleware() fiber.Handler { + return func(c fiber.Ctx) error { + // 可以在这里处理仓库名相关的逻辑 + return c.Next() + } +} diff --git a/internal/model/registry.go b/internal/model/registry.go new file mode 100644 index 0000000..de3f0fb --- /dev/null +++ b/internal/model/registry.go @@ -0,0 +1,162 @@ +package model + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "path/filepath" + "strings" + "time" + + "gorm.io/gorm" +) + +// Hash represents a content hash +type Hash string + +// NewHash creates a new Hash from a string +func NewHash(s string) (Hash, error) { + if !strings.HasPrefix(s, "sha256:") { + return "", fmt.Errorf("invalid hash format: %s", s) + } + return Hash(s), nil +} + +// String returns the string representation of the hash +func (h Hash) String() string { + return string(h) +} + +// Hex returns the hex part of the hash +func (h Hash) Hex() (string, error) { + if !strings.HasPrefix(string(h), "sha256:") { + return "", fmt.Errorf("invalid hash format: %s", h) + } + return strings.TrimPrefix(string(h), "sha256:"), nil +} + +// SHA256 computes the SHA256 hash of the given reader +func SHA256(r io.Reader) (Hash, int64, error) { + hasher := sha256.New() + n, err := io.Copy(hasher, r) + if err != nil { + return "", 0, err + } + return Hash("sha256:" + hex.EncodeToString(hasher.Sum(nil))), n, nil +} + +// RegistryRepository represents a repository in the registry +type RegistryRepository struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"uniqueIndex;not null" json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// RegistryManifest represents a manifest in the registry +type RegistryManifest struct { + ID uint `gorm:"primaryKey" json:"id"` + RepositoryID uint `gorm:"not null;index;uniqueIndex:idx_repo_digest" json:"repository_id"` + Repository RegistryRepository `gorm:"foreignKey:RepositoryID" json:"repository"` + Digest string `gorm:"not null;uniqueIndex:idx_repo_digest" json:"digest"` + Tag string `gorm:"index" json:"tag"` + ContentType string `gorm:"not null" json:"content_type"` + Size int64 `gorm:"not null" json:"size"` + Blob []byte `gorm:"type:blob" json:"blob"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// RegistryBlob represents a blob in the registry +type RegistryBlob struct { + ID uint `gorm:"primaryKey" json:"id"` + Digest string `gorm:"uniqueIndex;not null" json:"digest"` + Size int64 `gorm:"not null" json:"size"` + Path string `gorm:"not null" json:"path"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// RegistryTag represents a tag in the registry +type RegistryTag struct { + ID uint `gorm:"primaryKey" json:"id"` + RepositoryID uint `gorm:"not null;index" json:"repository_id"` + Repository RegistryRepository `gorm:"foreignKey:RepositoryID" json:"repository"` + Name string `gorm:"not null;index" json:"name"` + ManifestID uint `gorm:"not null;index" json:"manifest_id"` + Manifest RegistryManifest `gorm:"foreignKey:ManifestID" json:"manifest"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// RegistryUploadSession represents an upload session +type RegistryUploadSession struct { + ID uint `gorm:"primaryKey" json:"id"` + RepositoryID uint `gorm:"not null;index" json:"repository_id"` + Repository RegistryRepository `gorm:"foreignKey:RepositoryID" json:"repository"` + SessionID string `gorm:"uniqueIndex;not null" json:"session_id"` + Path string `gorm:"not null" json:"path"` + Size int64 `gorm:"not null" json:"size"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// Tag represents a tag list response +type Tag struct { + Name string `json:"name"` + Tags []string `json:"tags"` +} + +// Catalog represents a catalog response +type Catalog struct { + Repositories []string `json:"repositories"` +} + +// RepoSimpleManifest represents a simple manifest +type RepoSimpleManifest struct { + Blob []byte `json:"blob"` + ContentType string `json:"content_type"` +} + +// IndexManifest represents an index manifest +type IndexManifest struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + Manifests []IndexManifestEntry `json:"manifests"` +} + +// IndexManifestEntry represents an entry in an index manifest +type IndexManifestEntry struct { + MediaType string `json:"mediaType"` + Size int64 `json:"size"` + Digest string `json:"digest"` + Platform *Platform `json:"platform,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +// Platform represents a platform specification +type Platform struct { + Architecture string `json:"architecture"` + OS string `json:"os"` + Variant string `json:"variant,omitempty"` +} + +// GetBlobPath returns the file path for a blob +func GetBlobPath(baseDir, digest string) string { + hash := strings.TrimPrefix(digest, "sha256:") + if len(hash) < 4 { + return filepath.Join(baseDir, "registry", "blobs", "sha256", hash) + } + return filepath.Join(baseDir, "registry", "blobs", "sha256", hash[:2], hash[2:4], hash) +} + +// GetUploadPath returns the file path for an upload session +func GetUploadPath(baseDir, sessionID string) string { + return filepath.Join(baseDir, "registry", "uploads", sessionID) +} diff --git a/internal/registry/handlers/blob.go b/internal/registry/handlers/blob.go new file mode 100644 index 0000000..e7f3d33 --- /dev/null +++ b/internal/registry/handlers/blob.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + + "gitea.loveuer.com/loveuer/cluster/internal/registry/storage" + + "github.com/gin-gonic/gin" +) + +// GetBlob 获取 blob +func GetBlob(store storage.Storage) gin.HandlerFunc { + return func(c *gin.Context) { + repo := strings.TrimPrefix(c.Param("name"), "/") + digest := c.Param("digest") + + reader, size, err := store.GetBlob(repo, digest) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "errors": []gin.H{ + { + "code": "BLOB_UNKNOWN", + "message": err.Error(), + }, + }, + }) + return + } + defer reader.Close() + + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Length", strconv.FormatInt(size, 10)) + c.Header("Docker-Content-Digest", digest) + c.DataFromReader(http.StatusOK, size, "application/octet-stream", reader, nil) + } +} + +// HeadBlob 检查 blob 是否存在 +func HeadBlob(store storage.Storage) gin.HandlerFunc { + return func(c *gin.Context) { + repo := c.GetString("repo_name") + if repo == "" { + repo = strings.TrimPrefix(c.Param("name"), "/") + } + digest := c.Param("digest") + + exists, err := store.BlobExists(repo, digest) + if err != nil || !exists { + c.Status(http.StatusNotFound) + return + } + + size, err := store.GetBlobSize(repo, digest) + if err != nil { + c.Status(http.StatusNotFound) + return + } + + c.Header("Content-Length", strconv.FormatInt(size, 10)) + c.Header("Docker-Content-Digest", digest) + c.Status(http.StatusOK) + } +} diff --git a/internal/registry/handlers/manifest.go b/internal/registry/handlers/manifest.go new file mode 100644 index 0000000..ffa04a6 --- /dev/null +++ b/internal/registry/handlers/manifest.go @@ -0,0 +1,147 @@ +package handlers + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "strconv" + "strings" + + "gitea.loveuer.com/loveuer/cluster/internal/registry/storage" + + "github.com/gin-gonic/gin" +) + +// GetManifest 获取 manifest +func GetManifest(store storage.Storage) gin.HandlerFunc { + return func(c *gin.Context) { + repo := c.GetString("repo_name") + if repo == "" { + repo = strings.TrimPrefix(c.Param("name"), "/") + } + reference := c.Param("reference") + + data, mediaType, err := store.GetManifest(repo, reference) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "errors": []gin.H{ + { + "code": "MANIFEST_UNKNOWN", + "message": err.Error(), + }, + }, + }) + return + } + + c.Header("Content-Type", mediaType) + c.Header("Content-Length", strconv.FormatInt(int64(len(data)), 10)) + c.Header("Docker-Content-Digest", calculateDigest(data)) + c.Data(http.StatusOK, mediaType, data) + } +} + +// PutManifest 推送 manifest +func PutManifest(store storage.Storage) gin.HandlerFunc { + return func(c *gin.Context) { + repo := c.Param("name") + reference := c.Param("reference") + + // 读取请求体 + data, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "errors": []gin.H{ + { + "code": "BAD_REQUEST", + "message": err.Error(), + }, + }, + }) + return + } + + // 获取 Content-Type + mediaType := c.GetHeader("Content-Type") + if mediaType == "" { + mediaType = "application/vnd.docker.distribution.manifest.v2+json" + } + + // 存储 manifest + if err := store.PutManifest(repo, reference, data, mediaType); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "errors": []gin.H{ + { + "code": "INTERNAL_ERROR", + "message": err.Error(), + }, + }, + }) + return + } + + // 返回 Location 和 Digest + digest := calculateDigest(data) + c.Header("Location", c.Request.URL.Path) + c.Header("Docker-Content-Digest", digest) + c.Status(http.StatusCreated) + } +} + +// DeleteManifest 删除 manifest +func DeleteManifest(store storage.Storage) gin.HandlerFunc { + return func(c *gin.Context) { + repo := c.Param("name") + reference := c.Param("reference") + + if err := store.DeleteManifest(repo, reference); err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "errors": []gin.H{ + { + "code": "MANIFEST_UNKNOWN", + "message": err.Error(), + }, + }, + }) + return + } + + c.Status(http.StatusAccepted) + } +} + +// HeadManifest 检查 manifest 是否存在 +func HeadManifest(store storage.Storage) gin.HandlerFunc { + return func(c *gin.Context) { + repo := c.GetString("repo_name") + if repo == "" { + repo = strings.TrimPrefix(c.Param("name"), "/") + } + reference := c.Param("reference") + + exists, err := store.ManifestExists(repo, reference) + if err != nil || !exists { + c.Status(http.StatusNotFound) + return + } + + // 获取 manifest 以设置正确的 headers + data, mediaType, err := store.GetManifest(repo, reference) + if err != nil { + c.Status(http.StatusNotFound) + return + } + + c.Header("Content-Type", mediaType) + c.Header("Content-Length", strconv.FormatInt(int64(len(data)), 10)) + c.Header("Docker-Content-Digest", calculateDigest(data)) + c.Status(http.StatusOK) + } +} + +// calculateDigest 计算 SHA256 digest +func calculateDigest(data []byte) string { + hash := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(hash[:]) +} diff --git a/internal/registry/handlers/upload.go b/internal/registry/handlers/upload.go new file mode 100644 index 0000000..1a755ba --- /dev/null +++ b/internal/registry/handlers/upload.go @@ -0,0 +1,192 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + + "gitea.loveuer.com/loveuer/cluster/internal/registry/storage" + + "github.com/gin-gonic/gin" +) + +// StartBlobUpload 开始 blob 上传 +func StartBlobUpload(store storage.Storage) gin.HandlerFunc { + return func(c *gin.Context) { + repo := c.GetString("repo_name") + if repo == "" { + repo = strings.TrimPrefix(c.Param("name"), "/") + } + + uuid, err := store.StartBlobUpload(repo) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "errors": []gin.H{ + { + "code": "INTERNAL_ERROR", + "message": err.Error(), + }, + }, + }) + return + } + + // 返回上传 URL + location := c.Request.URL.Path + "/" + uuid + c.Header("Location", location) + c.Header("Docker-Upload-UUID", uuid) + c.Header("Range", "0-0") + c.Status(http.StatusAccepted) + } +} + +// PatchBlobUpload 上传 blob 数据块 +func PatchBlobUpload(store storage.Storage) gin.HandlerFunc { + return func(c *gin.Context) { + uuid := c.Param("uuid") + + // 获取 Range header + rangeHeader := c.GetHeader("Content-Range") + if rangeHeader == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "errors": []gin.H{ + { + "code": "BAD_REQUEST", + "message": "Content-Range header required", + }, + }, + }) + return + } + + // 读取数据 + data := c.Request.Body + if err := store.PutBlobUploadChunk(uuid, data); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "errors": []gin.H{ + { + "code": "INTERNAL_ERROR", + "message": err.Error(), + }, + }, + }) + return + } + + // 获取当前上传大小 + size, err := store.GetBlobUpload(uuid) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "errors": []gin.H{ + { + "code": "UPLOAD_UNKNOWN", + "message": err.Error(), + }, + }, + }) + return + } + + location := c.Request.URL.Path + c.Header("Location", location) + c.Header("Docker-Upload-UUID", uuid) + c.Header("Range", "0-"+strconv.FormatInt(size-1, 10)) + c.Status(http.StatusNoContent) + } +} + +// PutBlobUpload 完成 blob 上传 +func PutBlobUpload(store storage.Storage) gin.HandlerFunc { + return func(c *gin.Context) { + repo := c.GetString("repo_name") + if repo == "" { + repo = strings.TrimPrefix(c.Param("name"), "/") + } + uuid := c.Param("uuid") + + // 获取 digest + digest := c.Query("digest") + if digest == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "errors": []gin.H{ + { + "code": "BAD_REQUEST", + "message": "digest query parameter required", + }, + }, + }) + return + } + + // 如果有请求体,先追加数据 + if c.Request.ContentLength > 0 { + if err := store.PutBlobUploadChunk(uuid, c.Request.Body); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "errors": []gin.H{ + { + "code": "INTERNAL_ERROR", + "message": err.Error(), + }, + }, + }) + return + } + } + + // 完成上传 + if err := store.CompleteBlobUpload(repo, uuid, digest); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "errors": []gin.H{ + { + "code": "BAD_REQUEST", + "message": err.Error(), + }, + }, + }) + return + } + + // 清理上传文件 + store.DeleteBlobUpload(uuid) + + // 返回 blob 位置 + // 从 /v2/{name}/blobs/uploads/{uuid} 转换为 /v2/{name}/blobs/{digest} + pathParts := strings.Split(c.Request.URL.Path, "/") + if len(pathParts) >= 4 { + // 构建新的路径: /v2/{name}/blobs/{digest} + location := "/v2/" + pathParts[2] + "/blobs/" + digest + c.Header("Location", location) + } else { + c.Header("Location", c.Request.URL.Path) + } + c.Header("Content-Length", "0") + c.Header("Docker-Content-Digest", digest) + c.Status(http.StatusCreated) + } +} + +// GetBlobUpload 获取上传状态 +func GetBlobUpload(store storage.Storage) gin.HandlerFunc { + return func(c *gin.Context) { + uuid := c.Param("uuid") + + size, err := store.GetBlobUpload(uuid) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "errors": []gin.H{ + { + "code": "UPLOAD_UNKNOWN", + "message": err.Error(), + }, + }, + }) + return + } + + location := c.Request.URL.Path + c.Header("Location", location) + c.Header("Docker-Upload-UUID", uuid) + c.Header("Range", "0-"+strconv.FormatInt(size-1, 10)) + c.Status(http.StatusNoContent) + } +} diff --git a/internal/registry/handlers/version.go b/internal/registry/handlers/version.go new file mode 100644 index 0000000..1dad34b --- /dev/null +++ b/internal/registry/handlers/version.go @@ -0,0 +1,13 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// VersionCheck API 版本检查 +func VersionCheck(c *gin.Context) { + c.Header("Docker-Distribution-API-Version", "registry/2.0") + c.Status(http.StatusOK) +} diff --git a/internal/rerr/error.go b/internal/rerr/error.go new file mode 100644 index 0000000..dd9e1fa --- /dev/null +++ b/internal/rerr/error.go @@ -0,0 +1,102 @@ +package rerr + +import ( + "net/http" + + "github.com/gofiber/fiber/v3" +) + +// RepositoryError represents an error in the registry +type RepositoryError struct { + Status int `json:"status"` + Code string `json:"code"` + Message string `json:"message"` +} + +func (e *RepositoryError) Error() string { + return e.Message +} + +// Error responses +var ( + ErrBlobUnknown = &RepositoryError{ + Status: http.StatusNotFound, + Code: "BLOB_UNKNOWN", + Message: "blob unknown to registry", + } + + ErrDigestInvalid = &RepositoryError{ + Status: http.StatusBadRequest, + Code: "DIGEST_INVALID", + Message: "provided digest did not match uploaded content", + } + + ErrDigestMismatch = &RepositoryError{ + Status: http.StatusBadRequest, + Code: "DIGEST_MISMATCH", + Message: "provided digest did not match uploaded content", + } + + ErrManifestUnknown = &RepositoryError{ + Status: http.StatusNotFound, + Code: "MANIFEST_UNKNOWN", + Message: "manifest unknown", + } + + ErrManifestInvalid = &RepositoryError{ + Status: http.StatusBadRequest, + Code: "MANIFEST_INVALID", + Message: "manifest invalid", + } + + ErrNameUnknown = &RepositoryError{ + Status: http.StatusNotFound, + Code: "NAME_UNKNOWN", + Message: "repository name not known to registry", + } + + ErrUnauthorized = &RepositoryError{ + Status: http.StatusUnauthorized, + Code: "UNAUTHORIZED", + Message: "authentication required", + } + + ErrDenied = &RepositoryError{ + Status: http.StatusForbidden, + Code: "DENIED", + Message: "requested access to the resource is denied", + } + + ErrUnsupported = &RepositoryError{ + Status: http.StatusMethodNotAllowed, + Code: "UNSUPPORTED", + Message: "The operation is unsupported", + } + + ErrTooManyRequests = &RepositoryError{ + Status: http.StatusTooManyRequests, + Code: "TOOMANYREQUESTS", + Message: "too many requests", + } +) + +// ErrInternal creates an internal server error +func ErrInternal(err error) *RepositoryError { + return &RepositoryError{ + Status: http.StatusInternalServerError, + Code: "INTERNAL_SERVER_ERROR", + Message: err.Error(), + } +} + +// Error sends a repository error response +func Error(c fiber.Ctx, err *RepositoryError) error { + return c.Status(err.Status).JSON(fiber.Map{ + "errors": []fiber.Map{ + { + "code": err.Code, + "message": err.Message, + }, + }, + }) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4ed2c20 --- /dev/null +++ b/main.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "flag" + "log" + "os" + "os/signal" + "syscall" + "time" + + "gitea.loveuer.com/loveuer/cluster/api" + "gitea.loveuer.com/loveuer/cluster/internal/config" + "gitea.loveuer.com/loveuer/cluster/internal/database" + "gitea.loveuer.com/loveuer/cluster/internal/middleware" + "gitea.loveuer.com/loveuer/cluster/internal/registry/storage" + + "github.com/gofiber/fiber/v3" +) + +func main() { + // 解析命令行参数 + var ( + debug = flag.Bool("debug", false, "Enable debug mode") + address = flag.String("address", ":8080", "API server listen address") + dataDir = flag.String("data-dir", "./storage", "Data directory for storing all data") + ) + flag.Parse() + + // 加载配置 + cfg, err := config.LoadFromFlags(*debug, *address, *dataDir) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + log.Printf("Starting server with config: debug=%v, address=%s, data-dir=%s", + cfg.Server.Debug, cfg.Server.Address, cfg.Storage.RootPath) + + // 初始化数据库 + if err := database.Init(cfg.Storage.RootPath); err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + + // 初始化存储(使用 GORM 元数据 + 文件系统) + store, err := storage.NewGormStorage(cfg.Storage.RootPath) + if err != nil { + log.Fatalf("Failed to initialize storage: %v", err) + } + + // 创建 Fiber 应用 + config := fiber.Config{} + if !cfg.Server.Debug { + // Fiber v3 默认不显示启动消息 + } + app := fiber.New(config) + + // 全局中间件 + app.Use(middleware.Logger()) + app.Use(middleware.Recovery()) + app.Use(middleware.CORS()) + + // 健康检查 + app.Get("/health", func(c fiber.Ctx) error { + return c.JSON(fiber.Map{"status": "ok"}) + }) + + // 设置路由 + api.SetupRoutes(app, store) + + // 启动服务器(在 goroutine 中) + go func() { + log.Printf("Server listening on %s", cfg.Server.Address) + if err := app.Listen(cfg.Server.Address); err != nil { + log.Fatalf("Failed to start server: %v", err) + } + }() + + // 等待中断信号以优雅关闭服务器 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + // 优雅关闭,等待 5 秒 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := app.ShutdownWithContext(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server exited") +}