commit 29088a6b5417a509bc19e1f11cd785a6220d2429 Author: loveuer Date: Sun Nov 9 22:46:27 2025 +0800 feat: complete OCI registry implementation with docker push/pull support A lightweight OCI (Open Container Initiative) registry implementation written in Go. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d72fe82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# 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 +x-storage/ +*.db +main + +# Build artifacts and binaries +cluster +*.bin +/tmp/cluster +/tmp/cluster-* + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# Logs +*.log +x-* 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/dev.sh b/dev.sh new file mode 100755 index 0000000..72625cc --- /dev/null +++ b/dev.sh @@ -0,0 +1,78 @@ +#!/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:9119} +DATA_DIR=${DATA_DIR:-./x-storage} + +# Store PIDs for cleanup +BACKEND_PID="" +FRONTEND_PID="" + +cleanup() { + echo "" + echo "[dev] Shutting down..." + + # Kill backend if running + if [ -n "$BACKEND_PID" ] && kill -0 "$BACKEND_PID" 2>/dev/null; then + echo "[dev] Stopping backend (PID: $BACKEND_PID)..." + kill "$BACKEND_PID" 2>/dev/null || true + wait "$BACKEND_PID" 2>/dev/null || true + fi + + # Kill frontend if running + if [ -n "$FRONTEND_PID" ] && kill -0 "$FRONTEND_PID" 2>/dev/null; then + echo "[dev] Stopping frontend (PID: $FRONTEND_PID)..." + kill "$FRONTEND_PID" 2>/dev/null || true + wait "$FRONTEND_PID" 2>/dev/null || true + fi + + # Kill any remaining background jobs + if command -v xargs >/dev/null 2>&1; then + jobs -p | xargs kill 2>/dev/null || true + else + # Fallback for systems without xargs -r + for job in $(jobs -p); do + kill "$job" 2>/dev/null || true + done + fi + + # Wait a bit for graceful shutdown + sleep 1 + + # Force kill if still running + if [ -n "$BACKEND_PID" ] && kill -0 "$BACKEND_PID" 2>/dev/null; then + kill -9 "$BACKEND_PID" 2>/dev/null || true + fi + if [ -n "$FRONTEND_PID" ] && kill -0 "$FRONTEND_PID" 2>/dev/null; then + kill -9 "$FRONTEND_PID" 2>/dev/null || true + fi + + echo "[dev] Shutdown complete" + exit 0 +} + +trap cleanup INT TERM EXIT + +# Start backend +echo "[dev] Starting backend on $BACKEND_ADDR (data-dir=$DATA_DIR)" +go run . --debug --address "$BACKEND_ADDR" --data-dir "$DATA_DIR" & +BACKEND_PID=$! + +# Wait a moment for backend to start +sleep 2 + +# Start frontend +echo "[dev] Starting frontend dev server (Vite)" +(cd frontend && pnpm 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 +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..9bb54e4 --- /dev/null +++ b/frontend/src/pages/RegistryImageList.tsx @@ -0,0 +1,85 @@ +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 + upload_time: string + size: number +} + +// Format bytes to human readable format +function formatSize(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` +} + +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 result = await res.json() + // Backend returns: {status, msg, data: {images: [...]}} + const list: RegistryImage[] = result.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.upload_time} + {formatSize(img.size)} + + ))} + {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..72ae0a3 --- /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:9119', + 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..c1c5466 --- /dev/null +++ b/go.mod @@ -0,0 +1,35 @@ +module gitea.loveuer.com/loveuer/cluster + +go 1.25.0 + +require ( + github.com/glebarez/sqlite v1.11.0 + github.com/gofiber/fiber/v3 v3.0.0-beta.2 + github.com/spf13/cobra v1.10.1 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/gofiber/utils/v2 v2.0.0-rc.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.65.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..636eaef --- /dev/null +++ b/go.sum @@ -0,0 +1,73 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +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/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +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/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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/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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +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/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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/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= +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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..f4d51d6 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,61 @@ +package api + +import ( + "context" + "fmt" + "log" + "net" + + "gitea.loveuer.com/loveuer/cluster/internal/middleware" + "gitea.loveuer.com/loveuer/cluster/internal/module/registry" + "gitea.loveuer.com/loveuer/cluster/pkg/store" + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" +) + +func Init(ctx context.Context, address string, db *gorm.DB, store store.Store) error { + var ( + err error + ln net.Listener + cfg = fiber.Config{ + BodyLimit: 1024 * 1024 * 1024 * 10, // 10GB limit for large image layers + } + ) + + app := fiber.New(cfg) + + app.Use(middleware.Logger()) + app.Use(middleware.Recovery()) + app.Use(middleware.CORS()) + + // oci image apis + { + app.All("/v2/*", registry.Registry(ctx, db, store)) + } + + // registry image apis + { + registryAPI := app.Group("/api/v1/registry") + registryAPI.Get("/image/list", registry.RegistryImageList(ctx, db, store)) + } + + ln, err = net.Listen("tcp", address) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", address, err) + } + + go func() { + if err := app.Listener(ln); err != nil { + log.Fatalf("Fiber server failed on %s: %v", address, err) + } + }() + + go func() { + <-ctx.Done() + if err := app.Shutdown(); err != nil { + log.Fatalf("Failed to shutdown: %v", err) + } + }() + + return nil +} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go new file mode 100644 index 0000000..8fba766 --- /dev/null +++ b/internal/cmd/cmd.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "context" + + "gitea.loveuer.com/loveuer/cluster/internal/api" + "gitea.loveuer.com/loveuer/cluster/internal/opt" + "gitea.loveuer.com/loveuer/cluster/pkg/database/db" + "gitea.loveuer.com/loveuer/cluster/pkg/store" + "github.com/spf13/cobra" +) + +func Run(ctx context.Context) error { + _cmd := &cobra.Command{ + Use: "cluster", + Short: "Cluster is a lightweight OCI registry implementation written in Go using Fiber v3.", + RunE: func(cmd *cobra.Command, args []string) error { + var ( + err error + ) + + if err = opt.Init(cmd.Context()); err != nil { + return err + } + + if err = db.Init(cmd.Context(), opt.GlobalDataDir); err != nil { + return err + } + + if err = store.Init(cmd.Context(), opt.GlobalDataDir); err != nil { + return err + } + + if err = api.Init(cmd.Context(), opt.GlobalAddress, db.Default, store.Default); err != nil { + return err + } + + <-cmd.Context().Done() + + return nil + }, + } + + _cmd.PersistentFlags().BoolVar(&opt.GlobalDebug, "debug", false, "Enable debug mode") + _cmd.PersistentFlags().StringVarP(&opt.GlobalAddress, "address", "A", "0.0.0.0:9119", "API server listen address") + _cmd.PersistentFlags().StringVarP(&opt.GlobalDataDir, "data-dir", "D", "./x-storage", "Data directory for storing all data") + + return _cmd.Execute() +} 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/model.go b/internal/model/model.go new file mode 100644 index 0000000..8035248 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,70 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// Repository ???? +type Repository struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Name string `gorm:"uniqueIndex;not null" json:"name"` // ?????? "library/nginx" +} + +// Blob blob ?? +type Blob struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Digest string `gorm:"uniqueIndex;not null" json:"digest"` // SHA256 digest + Size int64 `gorm:"not null" json:"size"` // ?????? + MediaType string `json:"media_type"` // ???? + Repository string `gorm:"index" json:"repository"` // ??????????????? +} + +// Manifest manifest ?? +type Manifest struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Repository string `gorm:"index;not null" json:"repository"` // ???? + Tag string `gorm:"index;not null" json:"tag"` // tag ?? + Digest string `gorm:"uniqueIndex;not null" json:"digest"` // manifest digest + MediaType string `json:"media_type"` // ???? + Size int64 `gorm:"not null" json:"size"` // manifest ?? + Content []byte `gorm:"type:blob" json:"-"` // manifest ???JSON? +} + +// Tag tag ?????????? +type Tag struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Repository string `gorm:"index;not null" json:"repository"` // ???? + Tag string `gorm:"index;not null" json:"tag"` // tag ?? + Digest string `gorm:"not null" json:"digest"` // ??? manifest digest +} + +// BlobUpload ????? blob ?? +type BlobUpload struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + UUID string `gorm:"uniqueIndex;not null" json:"uuid"` // ???? UUID + Repository string `gorm:"index;not null" json:"repository"` // ???? + Path string `gorm:"not null" json:"path"` // ?????? + Size int64 `gorm:"default:0" json:"size"` // ????? +} diff --git a/internal/module/registry/blob.go b/internal/module/registry/blob.go new file mode 100644 index 0000000..09c828f --- /dev/null +++ b/internal/module/registry/blob.go @@ -0,0 +1,376 @@ +package registry + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "strconv" + "strings" + + "gitea.loveuer.com/loveuer/cluster/internal/model" + "gitea.loveuer.com/loveuer/cluster/pkg/resp" + "gitea.loveuer.com/loveuer/cluster/pkg/store" + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" +) + +// HandleBlobs ?? blob ???? +// POST /v2/{repo}/blobs/uploads/ - ???? +// PATCH /v2/{repo}/blobs/uploads/{uuid} - ????? +// PUT /v2/{repo}/blobs/uploads/{uuid}?digest={digest} - ???? +// GET /v2/{repo}/blobs/{digest} - ?? blob +// HEAD /v2/{repo}/blobs/{digest} - ?? blob ???? +func HandleBlobs(c fiber.Ctx, db *gorm.DB, store store.Store) error { + path := c.Path() + method := c.Method() + + // ????: /v2/{repo}/blobs/... + // ??????????? "test/redis" + pathWithoutV2 := strings.TrimPrefix(path, "/v2/") + parts := strings.Split(pathWithoutV2, "/") + if len(parts) < 2 { + return resp.R404(c, "INVALID_PATH", nil, "invalid path") + } + + // ?? "blobs" ???????????????? + blobsIndex := -1 + for i, part := range parts { + if part == "blobs" { + blobsIndex = i + break + } + } + if blobsIndex < 1 { + return resp.R404(c, "INVALID_PATH", nil, "invalid path: blobs not found") + } + + // ???? blobs ??????? + repo := strings.Join(parts[:blobsIndex], "/") + // ???? parts??????????? parts[0] ? "blobs" + parts = parts[blobsIndex:] + + switch method { + case "POST": + // POST /v2/{repo}/blobs/uploads/ - ???? + // parts ??? ["blobs", "uploads", ""] ? ["blobs", "uploads"] + if len(parts) >= 2 && parts[0] == "blobs" && parts[1] == "uploads" { + return handleBlobUploadStart(c, db, store, repo) + } + + case "PATCH": + // PATCH /v2/{repo}/blobs/uploads/{uuid} - ????? + // parts ??? ["blobs", "uploads", "uuid"] + if len(parts) >= 3 && parts[0] == "blobs" && parts[1] == "uploads" { + uuid := parts[2] + return handleBlobUploadChunk(c, db, store, repo, uuid) + } + + case "PUT": + // PUT /v2/{repo}/blobs/uploads/{uuid}?digest={digest} - ???? + // parts ??? ["blobs", "uploads", "uuid"] + if len(parts) >= 3 && parts[0] == "blobs" && parts[1] == "uploads" { + uuid := parts[2] + digest := c.Query("digest") + if digest == "" { + return resp.R400(c, "MISSING_DIGEST", nil, "digest parameter is required") + } + return handleBlobUploadComplete(c, db, store, repo, uuid, digest) + } + + case "GET": + // GET /v2/{repo}/blobs/{digest} - ?? blob + // parts ??? ["blobs", "digest"] + if len(parts) >= 2 && parts[0] == "blobs" { + digest := parts[1] + return handleBlobDownload(c, db, store, repo, digest) + } + + case "HEAD": + // HEAD /v2/{repo}/blobs/{digest} - ?? blob ???? + // parts ??? ["blobs", "digest"] + if len(parts) >= 2 && parts[0] == "blobs" { + digest := parts[1] + return handleBlobHead(c, db, store, repo, digest) + } + } + + return resp.R404(c, "NOT_FOUND", nil, "endpoint not found") +} + +// handleBlobUploadStart ?? blob ?? +func handleBlobUploadStart(c fiber.Ctx, db *gorm.DB, store store.Store, repo string) error { + // ?? UUID + uuidBytes := make([]byte, 16) + if _, err := rand.Read(uuidBytes); err != nil { + return resp.R500(c, "", nil, err) + } + uuid := hex.EncodeToString(uuidBytes) + + // ?????? + upload := &model.BlobUpload{ + UUID: uuid, + Repository: repo, + Path: uuid, // ?? UUID ?????? + Size: 0, + } + + if err := db.Create(upload).Error; err != nil { + return resp.R500(c, "", nil, err) + } + + // ?????? + w, err := store.CreateUpload(c.Context(), uuid) + if err != nil { + db.Delete(upload) + return resp.R500(c, "", nil, err) + } + w.Close() + + // ???? URL + uploadURL := fmt.Sprintf("/v2/%s/blobs/uploads/%s", repo, uuid) + c.Set("Location", uploadURL) + c.Set("Docker-Upload-UUID", uuid) + c.Set("Range", "0-0") + return c.SendStatus(202) +} + +// handleBlobUploadChunk ?? blob ??? +func handleBlobUploadChunk(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, uuid string) error { + // ?????? + var upload model.BlobUpload + if err := db.Where("uuid = ? AND repository = ?", uuid, repo).First(&upload).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return resp.R404(c, "UPLOAD_NOT_FOUND", nil, "upload session not found") + } + return resp.R500(c, "", nil, err) + } + + // ????? + body := c.Body() + if len(body) == 0 { + return resp.R400(c, "EMPTY_BODY", nil, "request body is empty") + } + + // ??????? bytes.NewReader ???????? + n, err := store.AppendUpload(c.Context(), uuid, bytes.NewReader(body)) + if err != nil { + return resp.R500(c, "", nil, err) + } + + // ?????? + upload.Size += n + if err := db.Save(&upload).Error; err != nil { + return resp.R500(c, "", nil, err) + } + + // ???? URL ??? + uploadURL := fmt.Sprintf("/v2/%s/blobs/uploads/%s", repo, uuid) + c.Set("Location", uploadURL) + c.Set("Docker-Upload-UUID", uuid) + c.Set("Range", fmt.Sprintf("0-%d", upload.Size-1)) + return c.SendStatus(202) +} + +// handleBlobUploadComplete ?? blob ?? +func handleBlobUploadComplete(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, uuid string, digest string) error { + // ?????? + var upload model.BlobUpload + if err := db.Where("uuid = ? AND repository = ?", uuid, repo).First(&upload).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return resp.R404(c, "UPLOAD_NOT_FOUND", nil, "upload session not found") + } + return resp.R500(c, "", nil, err) + } + + // ??????????????PUT ??????????? + body := c.Body() + if len(body) > 0 { + if _, err := store.AppendUpload(c.Context(), uuid, bytes.NewReader(body)); err != nil { + return resp.R500(c, "", nil, err) + } + } + + // ????????????? + if err := store.FinalizeUpload(c.Context(), uuid, digest); err != nil { + return resp.R500(c, "", nil, err) + } + + // ???????? + size, err := store.GetBlobSize(c.Context(), digest) + if err != nil { + return resp.R500(c, "", nil, err) + } + + // ????? blob ?? + var blob model.Blob + if err := db.Where("digest = ?", digest).First(&blob).Error; err != nil { + if err == gorm.ErrRecordNotFound { + blob = model.Blob{ + Digest: digest, + Size: size, + Repository: repo, + } + if err := db.Create(&blob).Error; err != nil { + return resp.R500(c, "", nil, err) + } + } else { + return resp.R500(c, "", nil, err) + } + } + + // ?????? + db.Delete(&upload) + store.DeleteUpload(c.Context(), uuid) + + // ?? blob URL + blobURL := fmt.Sprintf("/v2/%s/blobs/%s", repo, digest) + c.Set("Location", blobURL) + c.Set("Content-Length", fmt.Sprintf("%d", size)) + c.Set("Docker-Content-Digest", digest) + return c.SendStatus(201) +} + +// parseRangeHeader parses Range header and returns start and end positions +func parseRangeHeader(rangeHeader string, size int64) (start, end int64, valid bool) { + if rangeHeader == "" { + return 0, size - 1, false + } + + // Range header format: "bytes=start-end" or "bytes=start-" + if !strings.HasPrefix(rangeHeader, "bytes=") { + return 0, size - 1, false + } + + rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=") + parts := strings.Split(rangeSpec, "-") + if len(parts) != 2 { + return 0, size - 1, false + } + + var err error + if parts[0] == "" { + // Suffix range: "bytes=-suffix" + suffix, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || suffix <= 0 { + return 0, size - 1, false + } + start = size - suffix + if start < 0 { + start = 0 + } + end = size - 1 + } else if parts[1] == "" { + // Start range: "bytes=start-" + start, err = strconv.ParseInt(parts[0], 10, 64) + if err != nil || start < 0 || start >= size { + return 0, size - 1, false + } + end = size - 1 + } else { + // Full range: "bytes=start-end" + start, err = strconv.ParseInt(parts[0], 10, 64) + if err != nil || start < 0 || start >= size { + return 0, size - 1, false + } + end, err = strconv.ParseInt(parts[1], 10, 64) + if err != nil || end < start || end >= size { + return 0, size - 1, false + } + } + + return start, end, true +} + +// handleBlobDownload ?? blob +func handleBlobDownload(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, digest string) error { + // Check if blob exists + exists, err := store.BlobExists(c.Context(), digest) + if err != nil { + return resp.R500(c, "", nil, err) + } + if !exists { + return resp.R404(c, "BLOB_NOT_FOUND", nil, "blob not found") + } + + // Get blob size + size, err := store.GetBlobSize(c.Context(), digest) + if err != nil { + return resp.R500(c, "", nil, err) + } + + // Read blob + reader, err := store.ReadBlob(c.Context(), digest) + if err != nil { + return resp.R500(c, "", nil, err) + } + defer reader.Close() + + // Check for Range request + rangeHeader := c.Get("Range") + start, end, hasRange := parseRangeHeader(rangeHeader, size) + + if hasRange { + // Handle Range request + // Seek to start position + if seeker, ok := reader.(io.Seeker); ok { + if _, err := seeker.Seek(start, io.SeekStart); err != nil { + return resp.R500(c, "", nil, err) + } + } else { + // If not seekable, read and discard bytes + if _, err := io.CopyN(io.Discard, reader, start); err != nil { + return resp.R500(c, "", nil, err) + } + } + + // Create limited reader + limitedReader := io.LimitReader(reader, end-start+1) + + // Set partial content headers + c.Set("Content-Type", "application/octet-stream") + c.Set("Content-Length", fmt.Sprintf("%d", end-start+1)) + c.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size)) + c.Set("Accept-Ranges", "bytes") + c.Set("Docker-Content-Digest", digest) + c.Status(206) // Partial Content + + // Send partial content + return c.SendStream(limitedReader) + } + + // Full blob download + c.Set("Content-Type", "application/octet-stream") + c.Set("Content-Length", fmt.Sprintf("%d", size)) + c.Set("Accept-Ranges", "bytes") + c.Set("Docker-Content-Digest", digest) + + // Send full blob stream + return c.SendStream(reader) +} + +// handleBlobHead ?? blob ???? +func handleBlobHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, digest string) error { + // Check if blob exists + exists, err := store.BlobExists(c.Context(), digest) + if err != nil { + return resp.R500(c, "", nil, err) + } + if !exists { + return resp.R404(c, "BLOB_NOT_FOUND", nil, "blob not found") + } + + // Get blob size + size, err := store.GetBlobSize(c.Context(), digest) + if err != nil { + return resp.R500(c, "", nil, err) + } + + // Set response headers + c.Set("Content-Type", "application/octet-stream") + c.Set("Content-Length", fmt.Sprintf("%d", size)) + c.Set("Accept-Ranges", "bytes") + c.Set("Docker-Content-Digest", digest) + return c.SendStatus(200) +} diff --git a/internal/module/registry/catalog.go b/internal/module/registry/catalog.go new file mode 100644 index 0000000..016ac64 --- /dev/null +++ b/internal/module/registry/catalog.go @@ -0,0 +1,66 @@ +package registry + +import ( + "strconv" + "strings" + + "gitea.loveuer.com/loveuer/cluster/internal/model" + "gitea.loveuer.com/loveuer/cluster/pkg/resp" + "gitea.loveuer.com/loveuer/cluster/pkg/store" + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" +) + +// HandleCatalog ???????? +// GET /v2/_catalog?n={limit}&last={last} +func HandleCatalog(c fiber.Ctx, db *gorm.DB, store store.Store) error { + path := c.Path() + + // ????: /v2/_catalog + parts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/") + if len(parts) < 1 || parts[0] != "_catalog" { + return resp.R404(c, "INVALID_PATH", nil, "invalid path") + } + + // ?????? + nStr := c.Query("n", "100") + n, err := strconv.Atoi(nStr) + if err != nil || n <= 0 { + n = 100 + } + last := c.Query("last") + + // ???? + var repos []model.Repository + query := db.Order("name ASC").Limit(n + 1) + + if last != "" { + query = query.Where("name > ?", last) + } + + if err := query.Find(&repos).Error; err != nil { + return resp.R500(c, "", nil, err) + } + + // ???? + repoNames := make([]string, 0, len(repos)) + hasMore := false + for i, repo := range repos { + if i >= n { + hasMore = true + break + } + repoNames = append(repoNames, repo.Name) + } + + response := map[string]interface{}{ + "repositories": repoNames, + } + + // ?????????????? + if hasMore && len(repoNames) > 0 { + response["last"] = repoNames[len(repoNames)-1] + } + + return resp.R200(c, response) +} diff --git a/internal/module/registry/handler.list.go b/internal/module/registry/handler.list.go new file mode 100644 index 0000000..4246f93 --- /dev/null +++ b/internal/module/registry/handler.list.go @@ -0,0 +1,55 @@ +package registry + +import ( + "context" + + "gitea.loveuer.com/loveuer/cluster/internal/model" + "gitea.loveuer.com/loveuer/cluster/pkg/resp" + "gitea.loveuer.com/loveuer/cluster/pkg/store" + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" +) + +// RegistryImageList returns the list of images/repositories +func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler { + return func(c fiber.Ctx) error { + var repositories []model.Repository + + // Query all repositories from the database + if err := db.Find(&repositories).Error; err != nil { + return resp.R500(c, "", nil, err) + } + + // Convert to the expected format for the frontend + var result []map[string]interface{} + for _, repo := range repositories { + // Calculate total size of all blobs for this repository + var totalSize int64 + var sizeResult struct { + Total int64 + } + err := db.Model(&model.Blob{}). + Where("repository = ?", repo.Name). + Select("COALESCE(SUM(size), 0) as total"). + Scan(&sizeResult).Error + if err == nil { + totalSize = sizeResult.Total + } + + // Format updated_at to second precision + uploadTime := repo.UpdatedAt.Format("2006-01-02 15:04:05") + + repoMap := map[string]interface{}{ + "id": repo.ID, + "name": repo.Name, + "upload_time": uploadTime, + "size": totalSize, + } + result = append(result, repoMap) + } + + return resp.R200(c, map[string]interface{}{ + "images": result, + }) + } +} diff --git a/internal/module/registry/manifest.go b/internal/module/registry/manifest.go new file mode 100644 index 0000000..0cb71d9 --- /dev/null +++ b/internal/module/registry/manifest.go @@ -0,0 +1,419 @@ +package registry + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "gitea.loveuer.com/loveuer/cluster/internal/model" + "gitea.loveuer.com/loveuer/cluster/pkg/resp" + "gitea.loveuer.com/loveuer/cluster/pkg/store" + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" +) + +// isDigestFormat checks if a string is in digest format (e.g., sha256:abc123...) +func isDigestFormat(s string) bool { + parts := strings.SplitN(s, ":", 2) + if len(parts) != 2 { + return false + } + + algo := parts[0] + hash := parts[1] + + // Check algorithm + if algo != "sha256" { + // Could be extended to support other algorithms like sha512 + return false + } + + // Check that hash is a valid hex string of expected length (64 for sha256) + if len(hash) != 64 { + return false + } + + // Verify it's all hex characters + for _, r := range hash { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { + return false + } + } + + return true +} + +// HandleManifest ?? manifest ???? +// PUT /v2/{repo}/manifests/{tag} - ?? manifest +// GET /v2/{repo}/manifests/{tag} - ?? manifest +// DELETE /v2/{repo}/manifests/{tag} - ?? manifest +func HandleManifest(c fiber.Ctx, db *gorm.DB, store store.Store) error { + path := c.Path() + method := c.Method() + + // ????: /v2/{repo}/manifests/{tag} + // ??????????? "test/redis" + pathWithoutV2 := strings.TrimPrefix(path, "/v2/") + parts := strings.Split(pathWithoutV2, "/") + if len(parts) < 2 { + return resp.R404(c, "INVALID_PATH", nil, "invalid path") + } + + // ?? "manifests" ??? + manifestsIndex := -1 + for i, part := range parts { + if part == "manifests" { + manifestsIndex = i + break + } + } + if manifestsIndex < 1 || manifestsIndex >= len(parts)-1 { + return resp.R404(c, "INVALID_PATH", nil, "invalid path: manifests not found") + } + + // ???? manifests ??????? + repo := strings.Join(parts[:manifestsIndex], "/") + // tag ? manifests ????? + tag := parts[manifestsIndex+1] + + switch method { + case "PUT": + return handleManifestPut(c, db, store, repo, tag) + case "GET": + return handleManifestGet(c, db, store, repo, tag) + case "HEAD": + return handleManifestHead(c, db, store, repo, tag) + case "DELETE": + return handleManifestDelete(c, db, store, repo, tag) + } + + return resp.R404(c, "NOT_FOUND", nil, "method not allowed") +} + +// handleManifestPut ?? manifest +func handleManifestPut(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error { + // ?? manifest ?? + content := c.Body() + if len(content) == 0 { + return resp.R400(c, "EMPTY_BODY", nil, "manifest content is empty") + } + + // ?? digest + hasher := sha256.New() + hasher.Write(content) + digest := "sha256:" + hex.EncodeToString(hasher.Sum(nil)) + + // ?? Content-Type + mediaType := c.Get("Content-Type") + if mediaType == "" { + // ??? manifest ???? + var mf map[string]interface{} + if err := json.Unmarshal(content, &mf); err == nil { + if mt, ok := mf["mediaType"].(string); ok { + mediaType = mt + } else { + // ???? Docker manifest v2 + mediaType = "application/vnd.docker.distribution.manifest.v2+json" + } + } else { + mediaType = "application/vnd.docker.distribution.manifest.v2+json" + } + } + + // ?? manifest ???????? + var manifestData map[string]interface{} + if err := json.Unmarshal(content, &manifestData); err != nil { + return resp.R400(c, "INVALID_MANIFEST", nil, "invalid manifest format") + } + + // ?????? + var repository model.Repository + if err := db.Where("name = ?", repo).First(&repository).Error; err != nil { + if err == gorm.ErrRecordNotFound { + repository = model.Repository{Name: repo} + if err := db.Create(&repository).Error; err != nil { + return resp.R500(c, "", nil, err) + } + } else { + return resp.R500(c, "", nil, err) + } + } + + // ?? manifest ????? + if err := store.WriteManifest(c.Context(), digest, content); err != nil { + return resp.R500(c, "", nil, err) + } + + // ?? manifest ????? + var manifest model.Manifest + if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil { + if err == gorm.ErrRecordNotFound { + // ???? manifest ?? + manifest = model.Manifest{ + Repository: repo, + Tag: tag, + Digest: digest, + MediaType: mediaType, + Size: int64(len(content)), + Content: content, + } + if err := db.Create(&manifest).Error; err != nil { + return resp.R500(c, "", nil, err) + } + } else { + return resp.R500(c, "", nil, err) + } + } else { + // ???? manifest ? tag ?? + manifest.Tag = tag + manifest.Repository = repo + if err := db.Save(&manifest).Error; err != nil { + return resp.R500(c, "", nil, err) + } + } + + // ????? tag ?? + var tagRecord model.Tag + if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil { + if err == gorm.ErrRecordNotFound { + tagRecord = model.Tag{ + Repository: repo, + Tag: tag, + Digest: digest, + } + if err := db.Create(&tagRecord).Error; err != nil { + return resp.R500(c, "", nil, err) + } + } else { + return resp.R500(c, "", nil, err) + } + } else { + tagRecord.Digest = digest + if err := db.Save(&tagRecord).Error; err != nil { + return resp.R500(c, "", nil, err) + } + } + + // ????? + c.Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", repo, tag)) + c.Set("Docker-Content-Digest", digest) + c.Set("Content-Type", mediaType) + c.Set("Content-Length", fmt.Sprintf("%d", len(content))) + return c.SendStatus(201) +} + +// handleManifestGet ?? manifest +func handleManifestGet(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error { + var manifest model.Manifest + + // ?? tag ?????????????????????? + if isDigestFormat(tag) { + // ?? digest ???????????? repository + digest := tag + + // ?? manifest ??? + if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found") + } + return resp.R500(c, "", nil, err) + } + + // ???? manifest ?????????? repository ?? + var tagRecord model.Tag + if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository") + } + return resp.R500(c, "", nil, err) + } + } else { + // ?? tag ???? tag ????????? + var tagRecord model.Tag + if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found") + } + return resp.R500(c, "", nil, err) + } + + // ?? manifest ?? + if err := db.Where("digest = ?", tagRecord.Digest).First(&manifest).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found") + } + return resp.R500(c, "", nil, err) + } + } + + // Check Accept header if provided + acceptHeader := c.Get("Accept") + if acceptHeader != "" { + // Parse Accept header to check if client accepts the manifest's media type + acceptTypes := strings.Split(acceptHeader, ",") + accepted := false + for _, at := range acceptTypes { + // Remove quality values (e.g., "application/vnd.docker.distribution.manifest.v2+json;q=0.9") + mediaType := strings.TrimSpace(strings.Split(at, ";")[0]) + if mediaType == manifest.MediaType || mediaType == "*/*" { + accepted = true + break + } + } + if !accepted { + // Check for wildcard or common Docker manifest types + for _, at := range acceptTypes { + mediaType := strings.TrimSpace(strings.Split(at, ";")[0]) + if strings.Contains(mediaType, "manifest") || mediaType == "*/*" { + accepted = true + break + } + } + } + // Note: We still return the manifest even if not explicitly accepted, + // as some clients may not send proper Accept headers + } + + // Read manifest content + content, err := store.ReadManifest(c.Context(), manifest.Digest) + if err != nil { + return resp.R500(c, "", nil, err) + } + + // Set response headers + c.Set("Content-Type", manifest.MediaType) + c.Set("Content-Length", fmt.Sprintf("%d", len(content))) + c.Set("Docker-Content-Digest", manifest.Digest) + return c.Send(content) +} + +// handleManifestHead ?? manifest ???? +func handleManifestHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error { + var manifest model.Manifest + + // ?? tag ?????????????????????? + if isDigestFormat(tag) { + // ?? digest ???????????? repository + digest := tag + + // ?? manifest ??? + if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found") + } + return resp.R500(c, "", nil, err) + } + + // ???? manifest ?????????? repository ?? + var tagRecord model.Tag + if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository") + } + return resp.R500(c, "", nil, err) + } + } else { + // ?? tag ???? tag ????????? + var tagRecord model.Tag + if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found") + } + return resp.R500(c, "", nil, err) + } + + // ?? manifest ?? + if err := db.Where("digest = ?", tagRecord.Digest).First(&manifest).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found") + } + return resp.R500(c, "", nil, err) + } + } + + // ????? + c.Set("Content-Type", manifest.MediaType) + c.Set("Content-Length", fmt.Sprintf("%d", manifest.Size)) + c.Set("Docker-Content-Digest", manifest.Digest) + return c.SendStatus(200) +} + +// handleManifestDelete ?? manifest +func handleManifestDelete(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error { + var digest string + + if isDigestFormat(tag) { + // ?? digest ???????????? repository + digest = tag + + // ???? manifest ?????????? repository ?? + var tagRecord model.Tag + if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository") + } + return resp.R500(c, "", nil, err) + } + + // ???????? tag ??? manifest + var count int64 + if err := db.Model(&model.Tag{}).Where("digest = ?", digest).Count(&count).Error; err != nil { + return resp.R500(c, "", nil, err) + } + + // ??? tag ??????? manifest ?? + if count == 0 { + var manifest model.Manifest + if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found") + } + return resp.R500(c, "", nil, err) + } + + if err := db.Delete(&manifest).Error; err != nil { + return resp.R500(c, "", nil, err) + } + } else { + // ?? tag ????????????????? + // ??? manifest ??????????? + return resp.R400(c, "CANNOT_DELETE_DIGEST_REFERENCED_BY_TAGS", nil, "cannot delete manifest referenced by tags") + } + } else { + // ?? tag ???? tag ????????? + var tagRecord model.Tag + if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found") + } + return resp.R500(c, "", nil, err) + } + + digest = tagRecord.Digest + + // ?? tag ?? + if err := db.Delete(&tagRecord).Error; err != nil { + return resp.R500(c, "", nil, err) + } + + // ???????? tag ??? manifest + var count int64 + if err := db.Model(&model.Tag{}).Where("digest = ?", digest).Count(&count).Error; err != nil { + return resp.R500(c, "", nil, err) + } + + // ?????? tag ????? manifest ?? + if count == 0 { + var manifest model.Manifest + if err := db.Where("digest = ?", digest).First(&manifest).Error; err == nil { + if err := db.Delete(&manifest).Error; err != nil { + return resp.R500(c, "", nil, err) + } + } + } + } + + return c.SendStatus(202) +} diff --git a/internal/module/registry/referrer.go b/internal/module/registry/referrer.go new file mode 100644 index 0000000..beeae2c --- /dev/null +++ b/internal/module/registry/referrer.go @@ -0,0 +1,16 @@ +package registry + +import ( + "gitea.loveuer.com/loveuer/cluster/pkg/resp" + "gitea.loveuer.com/loveuer/cluster/pkg/store" + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" +) + +// HandleReferrers ?? referrers ???OCI ??? +// GET /v2/{repo}/referrers/{digest} +func HandleReferrers(c fiber.Ctx, db *gorm.DB, store store.Store) error { + // TODO: ?? OCI referrers API + // ????????????? OCI ? referrers ?? + return resp.R501(c, "NOT_IMPLEMENTED", nil, "referrers API not implemented yet") +} diff --git a/internal/module/registry/registry.go b/internal/module/registry/registry.go new file mode 100644 index 0000000..1f64429 --- /dev/null +++ b/internal/module/registry/registry.go @@ -0,0 +1,115 @@ +package registry + +import ( + "context" + "log" + "strings" + + "gitea.loveuer.com/loveuer/cluster/internal/model" + "gitea.loveuer.com/loveuer/cluster/pkg/resp" + "gitea.loveuer.com/loveuer/cluster/pkg/store" + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" +) + +func Registry(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler { + // ??????? + if err := db.AutoMigrate( + &model.Repository{}, + &model.Blob{}, + &model.Manifest{}, + &model.Tag{}, + &model.BlobUpload{}, + ); err != nil { + log.Fatalf("failed to migrate database: %v", err) + } + + if err := store.CreatePartition(ctx, "registry"); err != nil { + log.Fatalf("failed to create registry partition: %v", err) + } + + return func(c fiber.Ctx) error { + if isBlob(c) { + return HandleBlobs(c, db, store) + } + + if isManifest(c) { + return HandleManifest(c, db, store) + } + + if isTags(c) { + return HandleTags(c, db, store) + } + + if isCatalog(c) { + return HandleCatalog(c, db, store) + } + + if isReferrers(c) { + return HandleReferrers(c, db, store) + } + + // 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] Registry: unknown endpoint - path = %s, method = %s, headers = %v", c.Path(), c.Method(), &c.Request().Header) + + return resp.R404(c, "UNKNOWN_ENDPOINT", nil, "endpoint not found") + } +} + +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" +} diff --git a/internal/module/registry/tag.go b/internal/module/registry/tag.go new file mode 100644 index 0000000..e46a162 --- /dev/null +++ b/internal/module/registry/tag.go @@ -0,0 +1,84 @@ +package registry + +import ( + "strconv" + "strings" + + "gitea.loveuer.com/loveuer/cluster/internal/model" + "gitea.loveuer.com/loveuer/cluster/pkg/resp" + "gitea.loveuer.com/loveuer/cluster/pkg/store" + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" +) + +// HandleTags ?? tag ???? +// GET /v2/{repo}/tags/list?n={limit}&last={last} +func HandleTags(c fiber.Ctx, db *gorm.DB, store store.Store) error { + path := c.Path() + + // ????: /v2/{repo}/tags/list + // ??????????? "test/redis" + pathWithoutV2 := strings.TrimPrefix(path, "/v2/") + parts := strings.Split(pathWithoutV2, "/") + if len(parts) < 3 { + return resp.R404(c, "INVALID_PATH", nil, "invalid path") + } + + // ?? "tags" ??? + tagsIndex := -1 + for i, part := range parts { + if part == "tags" { + tagsIndex = i + break + } + } + if tagsIndex < 1 || tagsIndex >= len(parts)-1 || parts[tagsIndex+1] != "list" { + return resp.R404(c, "INVALID_PATH", nil, "invalid path") + } + + // ???? tags ??????? + repo := strings.Join(parts[:tagsIndex], "/") + + // ?????? + nStr := c.Query("n", "100") + n, err := strconv.Atoi(nStr) + if err != nil || n <= 0 { + n = 100 + } + last := c.Query("last") + + // ?? tags + var tags []model.Tag + query := db.Where("repository = ?", repo).Order("tag ASC").Limit(n + 1) + + if last != "" { + query = query.Where("tag > ?", last) + } + + if err := query.Find(&tags).Error; err != nil { + return resp.R500(c, "", nil, err) + } + + // ???? + tagNames := make([]string, 0, len(tags)) + hasMore := false + for i, tag := range tags { + if i >= n { + hasMore = true + break + } + tagNames = append(tagNames, tag.Tag) + } + + response := map[string]interface{}{ + "name": repo, + "tags": tagNames, + } + + // ?????????????? + if hasMore && len(tagNames) > 0 { + response["last"] = tagNames[len(tagNames)-1] + } + + return resp.R200(c, response) +} diff --git a/internal/opt/opt.go b/internal/opt/opt.go new file mode 100644 index 0000000..02b7106 --- /dev/null +++ b/internal/opt/opt.go @@ -0,0 +1,20 @@ +package opt + +import ( + "context" + "os" +) + +var ( + GlobalDebug bool + GlobalAddress string + GlobalDataDir string +) + +func Init(ctx context.Context) error { + if err := os.MkdirAll(GlobalDataDir, 0755); err != nil { + return err + } + + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d7f2f2d --- /dev/null +++ b/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "log" + "os/signal" + "syscall" + "time" + + "gitea.loveuer.com/loveuer/cluster/internal/cmd" +) + +func init() { + time.LoadLocation("Asia/Shanghai") +} + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + if err := cmd.Run(ctx); err != nil { + log.Fatalf("Failed to run command: %v", err) + } +} diff --git a/pkg/database/db/init.go b/pkg/database/db/init.go new file mode 100644 index 0000000..be412d6 --- /dev/null +++ b/pkg/database/db/init.go @@ -0,0 +1,27 @@ +package db + +import ( + "context" + "path/filepath" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +var ( + Default *gorm.DB +) + +func Init(ctx context.Context, dataDir string) error { + var ( + err error + dbPath = filepath.Join(dataDir, "cluster.db") + ) + + Default, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/resp/err.go b/pkg/resp/err.go new file mode 100644 index 0000000..396c72a --- /dev/null +++ b/pkg/resp/err.go @@ -0,0 +1,56 @@ +package resp + +import "net/http" + +type Error struct { + Status int `json:"status"` + Msg string `json:"msg"` + Err error `json:"err"` + Data any `json:"data"` +} + +func (e *Error) Error() string { + return e.Err.Error() +} + +func (e *Error) _r() *res { + data := &res{ + Status: e.Status, + Msg: e.Msg, + Data: e.Data, + Err: e.Err, + } + + if data.Status < 0 || data.Status > 999 { + data.Status = 500 + } + + return data +} + +func NewError(err error, args ...any) *Error { + e := &Error{ + Status: http.StatusInternalServerError, + Err: err, + } + + if len(args) > 0 { + if status, ok := args[0].(int); ok { + e.Status = status + } + } + + e.Msg = Msg(e.Status) + + if len(args) > 1 { + if msg, ok := args[1].(string); ok { + e.Msg = msg + } + } + + if len(args) > 2 { + e.Data = args[2] + } + + return e +} diff --git a/pkg/resp/i18n.go b/pkg/resp/i18n.go new file mode 100644 index 0000000..5703e07 --- /dev/null +++ b/pkg/resp/i18n.go @@ -0,0 +1,5 @@ +package resp + +func t(msg string) string { + return msg +} diff --git a/pkg/resp/msg.go b/pkg/resp/msg.go new file mode 100644 index 0000000..2827574 --- /dev/null +++ b/pkg/resp/msg.go @@ -0,0 +1,37 @@ +package resp + +const ( + Msg200 = "操作成功" + Msg201 = "操作需要审核, 请继续" + Msg202 = "操作未完成, 请继续" + Msg400 = "参数错误" + Msg400Duplicate = "目标已存在, 请勿重复创建" + Msg401 = "该账号登录已失效, 请重新登录" + Msg401NoMulti = "用户已在其他地方登录" + Msg403 = "权限不足" + Msg404 = "资源不存在" + Msg500 = "服务器开小差了" + Msg501 = "服务不可用" + Msg503 = "服务不可用或正在升级, 请联系管理员" +) + +func Msg(status int) string { + switch status { + case 400: + return Msg400 + case 401: + return Msg401 + case 403: + return Msg403 + case 404: + return Msg404 + case 500: + return Msg500 + case 501: + return Msg501 + case 503: + return Msg503 + } + + return "未知错误" +} diff --git a/pkg/resp/resp.go b/pkg/resp/resp.go new file mode 100644 index 0000000..0e075d7 --- /dev/null +++ b/pkg/resp/resp.go @@ -0,0 +1,185 @@ +package resp + +import ( + "errors" + "strings" + + "github.com/gofiber/fiber/v3" +) + +type res struct { + Status int `json:"status"` + Msg string `json:"msg"` + Data any `json:"data"` + Err any `json:"err"` +} + +func R200(c fiber.Ctx, data any, msgs ...string) error { + r := &res{ + Status: 200, + Msg: Msg200, + Data: data, + } + + if len(msgs) > 0 && msgs[0] != "" { + r.Msg = msgs[0] + } + + return c.JSON(r) +} + +func R201(c fiber.Ctx, data any, msgs ...string) error { + r := &res{ + Status: 201, + Msg: Msg201, + Data: data, + } + + if len(msgs) > 0 && msgs[0] != "" { + r.Msg = msgs[0] + } + + return c.JSON(r) +} + +func R202(c fiber.Ctx, data any, msgs ...string) error { + r := &res{ + Status: 202, + Msg: Msg202, + Data: data, + } + + if len(msgs) > 0 && msgs[0] != "" { + r.Msg = msgs[0] + } + + return c.JSON(r) +} + +func RC(c fiber.Ctx, status int, args ...any) error { + return _r(c, &res{Status: status}, args...) +} + +func RE(c fiber.Ctx, err error) error { + var re *Error + + if errors.As(err, &re) { + return RC(c, re.Status, re.Msg, re.Data, re.Err) + } + + estr := strings.ToLower(err.Error()) + if strings.Contains(estr, "duplicate") { + return R400(c, Msg400Duplicate, nil, estr) + } + + return R500(c, "", nil, err) +} + +func _r(c fiber.Ctx, r *res, args ...any) error { + length := len(args) + + if length == 0 { + goto END + } + + if length >= 1 { + if msg, ok := args[0].(string); ok { + r.Msg = msg + } + } + + if length >= 2 { + r.Data = args[1] + } + + if length >= 3 { + if ee, ok := args[2].(error); ok { + r.Err = ee.Error() + } else { + r.Err = args[2] + } + } +END: + + if r.Msg == "" { + r.Msg = Msg(r.Status) + } + + // todo: i18n r.Msg + // r.Msg = t(r.Msg) + + return c.Status(r.Status).JSON(r) +} + +// R400 +// +// args[0]: should be msg to display to user(defaulted) +// args[1]: could be extra data to send with(no default) +// args[2]: could be error msg to send to with debug mode +func R400(c fiber.Ctx, args ...any) error { + r := &res{ + Status: 400, + } + + return _r(c, r, args...) +} + +// R401 +// +// args[0]: should be msg to display to user(defaulted) +// args[1]: could be extra data to send with(no default) +// args[2]: could be error msg to send to with debug mode +func R401(c fiber.Ctx, args ...any) error { + r := &res{ + Status: 401, + } + + return _r(c, r, args...) +} + +// R403 +// +// args[0]: should be msg to display to user(defaulted) +// args[1]: could be extra data to send with(no default) +// args[2]: could be error msg to send to with debug mode +func R403(c fiber.Ctx, args ...any) error { + r := &res{ + Status: 403, + } + + return _r(c, r, args...) +} + +func R404(c fiber.Ctx, args ...any) error { + r := &res{ + Status: 404, + } + + return _r(c, r, args...) +} + +// R500 +// +// args[0]: should be msg to display to user(defaulted) +// args[1]: could be extra data to send with(no default) +// args[2]: could be error msg to send to with debug mode +func R500(c fiber.Ctx, args ...any) error { + r := &res{ + Status: 500, + } + + return _r(c, r, args...) +} + +// R501 +// +// args[0]: should be msg to display to user(defaulted) +// args[1]: could be extra data to send with(no default) +// args[2]: could be error msg to send to with debug mode +func R501(c fiber.Ctx, args ...any) error { + r := &res{ + Status: 501, + } + + return _r(c, r, args...) +} diff --git a/pkg/resp/sse.go b/pkg/resp/sse.go new file mode 100644 index 0000000..c156c73 --- /dev/null +++ b/pkg/resp/sse.go @@ -0,0 +1,75 @@ +package resp + +import ( + "bufio" + "encoding/json" + "fmt" + "time" + + "github.com/gofiber/fiber/v3" +) + +type SSEManager struct { + c fiber.Ctx + event string + ch chan string + id int64 +} + +func (m *SSEManager) Send(msg string) { + m.ch <- msg +} + +func (m *SSEManager) JSON(data any) { + bs, err := json.Marshal(data) + if err != nil { + m.ch <- err.Error() + return + } + + m.ch <- string(bs) +} + +func (m *SSEManager) Writer() func(w *bufio.Writer) { + return func(w *bufio.Writer) { + for msg := range m.ch { + fmt.Fprintf(w, "event: %s\nid: %d\ntimestamp: %d\ndata: %s\n\n", m.event, m.id, time.Now().UnixMilli(), msg) + w.Flush() + m.id++ + } + w.Flush() + } +} + +func (m *SSEManager) Close() { + close(m.ch) +} + +// SSE create a new SSEManager +// example: +// +// func someHandler(c fiber.Ctx) error { +// m := resp.SSE(c, "test") +// go func() { +// defer m.Close() +// for i := range 10 { +// m.Send("test" + strconv.Itoa(i)) +// time.Sleep(1 * time.Second) +// } +// }() +// +// return c.SendStreamWriter(m.Writer()) +// } +func SSE(c fiber.Ctx, event string) *SSEManager { + c.Set("Content-Type", "text/event-stream") + c.Set("Cache-Control", "no-cache") + c.Set("Connection", "keep-alive") + c.Set("Transfer-Encoding", "chunked") + + return &SSEManager{ + c: c, + event: event, + id: 0, + ch: make(chan string, 1), + } +} diff --git a/pkg/store/store.go b/pkg/store/store.go new file mode 100644 index 0000000..461ff98 --- /dev/null +++ b/pkg/store/store.go @@ -0,0 +1,341 @@ +package store + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +type Store interface { + CreatePartition(ctx context.Context, name string) error + // Blob ?? + WriteBlob(ctx context.Context, digest string, r io.Reader) error + ReadBlob(ctx context.Context, digest string) (io.ReadCloser, error) + BlobExists(ctx context.Context, digest string) (bool, error) + GetBlobSize(ctx context.Context, digest string) (int64, error) + // Manifest ?? + WriteManifest(ctx context.Context, digest string, content []byte) error + ReadManifest(ctx context.Context, digest string) ([]byte, error) + ManifestExists(ctx context.Context, digest string) (bool, error) + // Upload ?? + CreateUpload(ctx context.Context, uuid string) (io.WriteCloser, error) + AppendUpload(ctx context.Context, uuid string, r io.Reader) (int64, error) + GetUploadSize(ctx context.Context, uuid string) (int64, error) + FinalizeUpload(ctx context.Context, uuid string, digest string) error + DeleteUpload(ctx context.Context, uuid string) error +} + +type fileStore struct { + baseDir string +} + +var ( + Default Store +) + +func Init(ctx context.Context, dataDir string) error { + Default = &fileStore{ + baseDir: dataDir, + } + return nil +} + +func (s *fileStore) CreatePartition(ctx context.Context, name string) error { + dirs := []string{ + filepath.Join(s.baseDir, name, "blobs"), + filepath.Join(s.baseDir, name, "manifests"), + filepath.Join(s.baseDir, name, "uploads"), + } + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + } + return nil +} + +// blobPath ?? digest ?? blob ???? +// ??: blobs/sha256/abc/def.../digest +func (s *fileStore) blobPath(digest string) (string, error) { + // ?? digest???: sha256:abc123... + parts := strings.SplitN(digest, ":", 2) + if len(parts) != 2 { + return "", fmt.Errorf("invalid digest format: %s", digest) + } + algo := parts[0] + hash := parts[1] + + if algo != "sha256" { + return "", fmt.Errorf("unsupported digest algorithm: %s", algo) + } + + // ??? 2 ????????????? 2 ????????? + if len(hash) < 4 { + return "", fmt.Errorf("invalid hash length: %s", hash) + } + + path := filepath.Join(s.baseDir, "registry", "blobs", algo, hash[:2], hash[2:4], hash) + return path, nil +} + +func (s *fileStore) WriteBlob(ctx context.Context, digest string, r io.Reader) error { + path, err := s.blobPath(digest) + if err != nil { + return err + } + + // ??????? + if _, err := os.Stat(path); err == nil { + return nil // ???????? + } + + // ???? + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("failed to create blob directory: %w", err) + } + + // ???? + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create blob file: %w", err) + } + defer f.Close() + + // ???? digest ?? + hasher := sha256.New() + tee := io.TeeReader(r, hasher) + + if _, err := io.Copy(f, tee); err != nil { + os.Remove(path) + return fmt.Errorf("failed to write blob: %w", err) + } + + // ?? digest + calculated := "sha256:" + hex.EncodeToString(hasher.Sum(nil)) + if calculated != digest { + os.Remove(path) + return fmt.Errorf("digest mismatch: expected %s, got %s", digest, calculated) + } + + return nil +} + +func (s *fileStore) ReadBlob(ctx context.Context, digest string) (io.ReadCloser, error) { + path, err := s.blobPath(digest) + if err != nil { + return nil, err + } + + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("blob not found: %w", err) + } + + return f, nil +} + +func (s *fileStore) BlobExists(ctx context.Context, digest string) (bool, error) { + path, err := s.blobPath(digest) + if err != nil { + return false, err + } + + _, err = os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func (s *fileStore) GetBlobSize(ctx context.Context, digest string) (int64, error) { + path, err := s.blobPath(digest) + if err != nil { + return 0, err + } + + info, err := os.Stat(path) + if err != nil { + return 0, err + } + + return info.Size(), nil +} + +// manifestPath ?? digest ?? manifest ???? +func (s *fileStore) manifestPath(digest string) (string, error) { + parts := strings.SplitN(digest, ":", 2) + if len(parts) != 2 { + return "", fmt.Errorf("invalid digest format: %s", digest) + } + algo := parts[0] + hash := parts[1] + + if algo != "sha256" { + return "", fmt.Errorf("unsupported digest algorithm: %s", algo) + } + + path := filepath.Join(s.baseDir, "registry", "manifests", algo, hash[:2], hash[2:4], hash) + return path, nil +} + +func (s *fileStore) WriteManifest(ctx context.Context, digest string, content []byte) error { + path, err := s.manifestPath(digest) + if err != nil { + return err + } + + // ???? + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("failed to create manifest directory: %w", err) + } + + // ?? digest + hasher := sha256.New() + hasher.Write(content) + calculated := "sha256:" + hex.EncodeToString(hasher.Sum(nil)) + if calculated != digest { + return fmt.Errorf("digest mismatch: expected %s, got %s", digest, calculated) + } + + // ???? + if err := os.WriteFile(path, content, 0644); err != nil { + return fmt.Errorf("failed to write manifest: %w", err) + } + + return nil +} + +func (s *fileStore) ReadManifest(ctx context.Context, digest string) ([]byte, error) { + path, err := s.manifestPath(digest) + if err != nil { + return nil, err + } + + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("manifest not found: %w", err) + } + + return content, nil +} + +func (s *fileStore) ManifestExists(ctx context.Context, digest string) (bool, error) { + path, err := s.manifestPath(digest) + if err != nil { + return false, err + } + + _, err = os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +// uploadPath ?????????? +func (s *fileStore) uploadPath(uuid string) string { + return filepath.Join(s.baseDir, "registry", "uploads", uuid) +} + +func (s *fileStore) CreateUpload(ctx context.Context, uuid string) (io.WriteCloser, error) { + path := s.uploadPath(uuid) + + // ???? + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, fmt.Errorf("failed to create upload directory: %w", err) + } + + f, err := os.Create(path) + if err != nil { + return nil, fmt.Errorf("failed to create upload file: %w", err) + } + + return f, nil +} + +func (s *fileStore) AppendUpload(ctx context.Context, uuid string, r io.Reader) (int64, error) { + path := s.uploadPath(uuid) + + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return 0, fmt.Errorf("failed to open upload file: %w", err) + } + defer f.Close() + + n, err := io.Copy(f, r) + if err != nil { + return 0, fmt.Errorf("failed to write to upload: %w", err) + } + + return n, nil +} + +func (s *fileStore) GetUploadSize(ctx context.Context, uuid string) (int64, error) { + path := s.uploadPath(uuid) + + info, err := os.Stat(path) + if err != nil { + return 0, err + } + + return info.Size(), nil +} + +func (s *fileStore) FinalizeUpload(ctx context.Context, uuid string, digest string) error { + uploadPath := s.uploadPath(uuid) + blobPath, err := s.blobPath(digest) + if err != nil { + return err + } + + // ???? blob ???????????? + if _, err := os.Stat(blobPath); err == nil { + os.Remove(uploadPath) + return nil + } + + // ?????? + if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil { + return fmt.Errorf("failed to create blob directory: %w", err) + } + + // ?? digest + f, err := os.Open(uploadPath) + if err != nil { + return fmt.Errorf("failed to open upload file: %w", err) + } + defer f.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, f); err != nil { + return fmt.Errorf("failed to calculate digest: %w", err) + } + + calculated := "sha256:" + hex.EncodeToString(hasher.Sum(nil)) + if calculated != digest { + return fmt.Errorf("digest mismatch: expected %s, got %s", digest, calculated) + } + + // ???? + if err := os.Rename(uploadPath, blobPath); err != nil { + return fmt.Errorf("failed to finalize upload: %w", err) + } + + return nil +} + +func (s *fileStore) DeleteUpload(ctx context.Context, uuid string) error { + path := s.uploadPath(uuid) + return os.Remove(path) +}