diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..c7393ed --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,59 @@ +name: Deploy Docs to GitHub Pages + +on: + push: + tags: + - 'docs*' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Generate proto code + run: | + docker run --rm \ + --volume "${{ github.workspace }}:/workspace" \ + --workdir /workspace \ + bufbuild/buf:latest generate + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + working-directory: docs/discopanel + run: npm ci + + - name: Build docs + working-directory: docs/discopanel + run: npm run build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/discopanel/dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d4928d..e1727b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: uses: actions/download-artifact@v4 with: name: api-schema-yaml - path: web/discopanel/static/schemav1.yaml + path: web/discopanel/static - name: Set up Node.js uses: actions/setup-node@v4 @@ -161,7 +161,7 @@ jobs: CC: ${{ matrix.cc || '' }} CXX: ${{ matrix.cxx || '' }} run: | - go build -tags embed -o discopanel-${{ matrix.suffix }} ./cmd/discopanel + go build -o discopanel-${{ matrix.suffix }} ./cmd/discopanel - name: Compress binary (non-Windows) if: matrix.goos != 'windows' diff --git a/.gitignore b/.gitignore index a43fc8b..26f8a13 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ sandbox/ build/ discopanel !proto/discopanel +!docs/discopanel +web/discopanel/src/lib/proto/gnostic/ !cmd/discopanel !web/discopanel data/ diff --git a/Makefile b/Makefile index 3a24abd..7f9f950 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ -.PHONY: dev prod clean build build-frontend run deps test fmt lint check help kill-dev image dev-docker modules proto proto-clean proto-lint proto-format proto-breaking gen +.PHONY: dev prod clean build build-frontend run deps test fmt lint check help kill-dev image dev-docker dev-auth modules proto proto-clean proto-lint proto-format proto-breaking gen dev-docs DATA_DIR := ./data +DOCKER_DATA_DIR := /tmp/discopanel DB_FILE := $(DATA_DIR)/discopanel.db FRONTEND_DIR := web/discopanel DISCOPANEL_BIN := build/discopanel @@ -28,17 +29,22 @@ run: dev: clean run -# Build and run docker container for local dev -dev-docker: - @echo "Building and running Docker container for development..." - docker compose -f docker-compose.dev.yaml build --no-cache - docker compose -f docker-compose.dev.yaml up - +# Build and run with OIDC provider (Keycloak) +dev-auth-%: clean + docker compose -f oidc/$*/docker-compose.yaml down -v --remove-orphans + @docker run --rm -v /tmp:/tmp alpine rm -rf /tmp/discopanel + @echo "Building and running with OIDC provider..." + docker compose -f oidc/$*/docker-compose.yaml build --no-cache + docker compose -f oidc/$*/docker-compose.yaml up + +dev-docs: + cd docs/discopanel && npm run dev + # Production build and run prod: build-frontend @echo "Building for production..." @mkdir -p $(DATA_DIR) - go build -tags embed -o $(DISCOPANEL_BIN) cmd/discopanel/main.go + go build -o $(DISCOPANEL_BIN) cmd/discopanel/main.go # Build frontend for production build-frontend: @@ -90,6 +96,10 @@ clean: echo "Removing data directory..."; \ rm -rf $(DATA_DIR); \ fi + @if [ -d "$(DOCKER_DATA_DIR)" ]; then \ + echo "Removing docker data directory..."; \ + docker run --rm -v $(DOCKER_DATA_DIR):/tmp alpine sh -c 'rm -rf /tmp/*'; \ + fi @if [ -f "$(DISCOPANEL_BIN)" ]; then \ echo "Removing backend binary..."; \ rm -f $(DISCOPANEL_BIN); \ @@ -179,6 +189,7 @@ help: @echo " make prod - Build and run in production mode" @echo " make image - Build and push Docker image to :dev tag" @echo " make dev-docker - Build and run Docker container locally (no cache)" + @echo " make dev-auth - Build and run with OIDC provider (Keycloak)" @echo " make modules - Build and push all module Docker images" @echo " make clean - Remove data directory and build artifacts" @echo " make kill-dev - Kill any orphaned dev processes" diff --git a/README.md b/README.md index 77014cf..5245f91 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,22 @@ make gen docker run --rm -v "$(pwd):/workspace" -w /workspace bufbuild/buf:latest generate ``` +## Docs + +The doc site lives in `docs/discopanel/` and is built with [Astro](https://astro.build) + [Starlight](https://starlight.astro.build). Deployed to [docs.discopanel.app](https://docs.discopanel.app). + +### Contributing, building, & viewing locally + +```bash +make dev-docs +``` + +Open `http://localhost:4321`. + +### Reporting doc issues + +If something is wrong or missing, open an issue on [GitHub](https://github.com/nickheyer/discopanel/issues) or mention it in [Discord](https://discord.gg/6Z9yKTbsrP). + ## License MIT. Do whatever you want with it, just don't blame me when it breaks. diff --git a/buf.gen.yaml b/buf.gen.yaml index c4bdee6..c9cfba2 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -5,6 +5,11 @@ plugins: opt: - path=schemav1.yaml + - remote: buf.build/community/sudorandom-connect-openapi:v0.21.3 + out: docs/discopanel/public + opt: + - path=schemav1.yaml + # Generate Go code with protoc-gen-go - remote: buf.build/protocolbuffers/go out: pkg/proto @@ -22,4 +27,5 @@ plugins: out: web/discopanel/src/lib/proto opt: - target=ts - - import_extension=none \ No newline at end of file + - import_extension=none + include_imports: true diff --git a/buf.lock b/buf.lock index 4f98143..4e8c317 100644 --- a/buf.lock +++ b/buf.lock @@ -1,2 +1,9 @@ # Generated by buf. DO NOT EDIT. version: v2 +deps: + - name: buf.build/gnostic/gnostic + commit: 087bc8072ce44e339f213209e4d57bf0 + digest: b5:c4eebcd04bc2fdd5dd0b8d695eb419682a650b600cdb56ff2ed61208a24603e0eb1b8ae0d467925c69a24bde6d322f3c4112bd2b8efdd682d8c3128384cdac9a + - name: buf.build/googleapis/googleapis + commit: 004180b77378443887d3b55cabc00384 + digest: b5:e8f475fe3330f31f5fd86ac689093bcd274e19611a09db91f41d637cb9197881ce89882b94d13a58738e53c91c6e4bae7dc1feba85f590164c975a89e25115dc diff --git a/buf.yaml b/buf.yaml index 8508a31..c7a6af6 100644 --- a/buf.yaml +++ b/buf.yaml @@ -20,4 +20,5 @@ breaking: - EXTENSION_NO_DELETE - FIELD_SAME_DEFAULT deps: - - buf.build/googleapis/googleapis \ No newline at end of file + - buf.build/googleapis/googleapis + - buf.build/gnostic/gnostic \ No newline at end of file diff --git a/cmd/discopanel/main.go b/cmd/discopanel/main.go index 9b61364..6612aca 100644 --- a/cmd/discopanel/main.go +++ b/cmd/discopanel/main.go @@ -56,36 +56,14 @@ func main() { } } - // Initialize storage with connection pooling - store, err := storage.NewSQLiteStore(cfg.Database.Path, storage.DBConfig{ - MaxOpenConns: cfg.Database.MaxConnections, - MaxIdleConns: cfg.Database.MaxIdleConns, - ConnMaxLifetime: time.Duration(cfg.Database.ConnMaxLifetime) * time.Second, - }) + // Initialize storage w/ migrations and seeding + store, err := storage.NewSQLiteStore(cfg) if err != nil { log.Fatal("Failed to initialize storage: %v", err) } defer store.Close() - // Initialize global settings with config defaults if they don't exist ctx := context.Background() - _, isNew, err := store.GetGlobalSettings(ctx) - if err != nil { - log.Fatal("Failed to get global settings: %v", err) - } - - // Check if global settings are empty (just created) and populate with config defaults - if isNew || cfg.Minecraft.ResetGlobal { - // Copy the config defaults to global settings - globalConfig := config.LoadGlobalServerConfig(cfg) - globalConfig.ID = storage.GlobalSettingsID - globalConfig.ServerID = storage.GlobalSettingsID - - if err := store.UpdateGlobalSettings(ctx, &globalConfig); err != nil { - log.Fatal("Failed to initialize global settings: %v", err) - } - log.Info("Initialized global settings from config file") - } // Initialize Docker client with configuration dockerClient, err := docker.NewClient(cfg.Docker.Host, log, docker.ClientConfig{ diff --git a/config.example.yaml b/config.example.yaml index 475f1ce..484d579 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,5 +1,14 @@ # DiscoPanel Configuration File # This file contains all configuration options for DiscoPanel +# +# These can all be set via env, like we do in our docker compose example(s). For example: +# +# server: +# port: "8080" +# +# - is the same as - +# +# DISCOPANEL_SERVER_PORT="8080" # Server configuration for the DiscoPanel application itself server: @@ -8,6 +17,7 @@ server: read_timeout: 15 write_timeout: 15 idle_timeout: 60 + user_agent: "DiscoPanel/1.0 (github.com/nickheyer/discopanel)" # Database configuration database: @@ -31,6 +41,42 @@ storage: temp_dir: "./tmp" max_upload_size: 524288000 # 500MB in bytes +# Authentication configuration +auth: + session_timeout: 86400 # default: 24 hours + anonymous_access: false # Allow unauthenticated access + jwt_secret: "" # Leave empty to auto-generate + + # Local authentication (login via discopanel web ui) + local: + enabled: true # Enable local + allow_registration: false # Allow new users to register themselves via login + + # OIDC authentication (login via OIDC-compliant provider, ie: keycloak, authelia, authentik, etc.) + oidc: + enabled: false + issuer_uri: "" # OIDC Provider url (ie: http://authelia.local:9091, http://localhost:8180/realms/discopanel, etc.) + client_id: "" # Client ID registered with your OIDC (like "discopanel") + client_secret: "" # Client secret registered with your OIDC + redirect_url: "" # Where OIDC sends users after login (ie: "http://localhost:8080/api/v1/auth/oidc/callback") + scopes: ["openid", "profile", "email"] + role_claim: "groups" # The token claim that contains the user's groups (usually "groups") + role_mapping: {} # Mapping to groups if they arent the same name as discopanels (ie: {"my-admins": "admin", "my-users": "user"}) + skip_tls_verify: false # Skip TLS certificate verification (for self-signed certs) + +# Upload configuration +upload: + session_ttl: 240 # Upload session time-to-live in minutes (default: 4 hours) + default_chunk_size: 5242880 # 5MB default chunk size (client can override) + max_chunk_size: 10485760 # 10MB max chunk size (server enforced) + max_upload_size: 0 # Max total upload size in bytes (0 = unlimited) + +# Module configuration +module: + enabled: true + port_range_min: 8100 + port_range_max: 8199 + # Proxy configuration - ENABLE THIS FOR MINECRAFT ROUTING proxy: enabled: true diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml deleted file mode 100644 index afdc0ae..0000000 --- a/docker-compose.dev.yaml +++ /dev/null @@ -1,18 +0,0 @@ -services: - discopanel: - build: - context: . - dockerfile: docker/Dockerfile.discopanel - container_name: discopanel-dev - restart: "no" - network_mode: host - - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - /tmp/discopanel:/app/data - environment: - - DISCOPANEL_DATA_DIR=/app/data - - DISCOPANEL_HOST_DATA_PATH=/tmp/discopanel - - TZ=UTC - extra_hosts: - - "host.docker.internal:host-gateway" diff --git a/docker-compose.yml b/docker-compose.yml index 1151fc2..36a8658 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,12 +27,11 @@ services: # You can set this to any path you'd like, but the path must exist AND you must use the same # absolute paths below for the below env vars (in the environment section at the bottom). Example: # DISCOPANEL_DATA_DIR=/app/data - # DISCOPANEL_HOST_DATA_PATH=/home/user/data + # DISCOPANEL_HOST_DATA_PATH=/opt/discopanel/data # (See environment) - - /home/user/data:/app/data - - - ./backups:/app/backups - - ./tmp:/app/tmp + - /opt/discopanel/data:/app/data + - /opt/discopanel/backups:/app/backups + - /tmp/discopanel:/app/tmp # Configuration file, uncomment if you are using a config file (optional, see config.example.yaml for all available options). #- ./config.yaml:/app/config.yaml:ro @@ -40,9 +39,19 @@ services: - DISCOPANEL_DATA_DIR=/app/data # IMPORTANT: THIS MUST BE SET TO THE SAME PATH AS THE SERVER DATA PATH IN "volumes" above - - DISCOPANEL_HOST_DATA_PATH=/home/user/data + - DISCOPANEL_HOST_DATA_PATH=/opt/discopanel/data - TZ=UTC + # ── Authentication ────────────────────────── + # Local auth (username/password) is on by default. + # You create your first admin account on first login. + # + # Want to let new users sign up on their own? + # - DISCOPANEL_AUTH_LOCAL_ALLOW_REGISTRATION=true + # + # Want single sign-on (OIDC) with Keycloak, Authelia, etc? + # See the oidc/ folder for examples. + # DONT FORGET THIS extra_hosts: - "host.docker.internal:host-gateway" diff --git a/docker/Dockerfile.discopanel b/docker/Dockerfile.discopanel index c0c0ec6..3b7c3f2 100644 --- a/docker/Dockerfile.discopanel +++ b/docker/Dockerfile.discopanel @@ -3,11 +3,11 @@ ARG APP_VERSION=dev # Generate protobuf code using buf FROM bufbuild/buf:latest AS proto-builder WORKDIR /app -COPY buf.yaml buf.gen.yaml ./ +COPY buf.yaml buf.gen.yaml buf.lock ./ COPY proto/ ./proto/ RUN mkdir -p web/discopanel/static pkg/proto web/discopanel/src/lib/proto && buf generate -FROM node:22-alpine AS frontend-builder +FROM node:alpine AS frontend-builder ARG APP_VERSION ENV APP_VERSION=${APP_VERSION} @@ -23,7 +23,7 @@ COPY --from=proto-builder /app/web/discopanel/static/schemav1.yaml ./static/ RUN npm run build -FROM golang:1.24.5-alpine AS backend-builder +FROM golang:alpine AS backend-builder ARG APP_VERSION ENV APP_VERSION=${APP_VERSION} diff --git a/docs/discopanel/.gitignore b/docs/discopanel/.gitignore new file mode 100644 index 0000000..6240da8 --- /dev/null +++ b/docs/discopanel/.gitignore @@ -0,0 +1,21 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store diff --git a/docs/discopanel/.vscode/extensions.json b/docs/discopanel/.vscode/extensions.json new file mode 100644 index 0000000..22a1505 --- /dev/null +++ b/docs/discopanel/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["astro-build.astro-vscode"], + "unwantedRecommendations": [] +} diff --git a/docs/discopanel/.vscode/launch.json b/docs/discopanel/.vscode/launch.json new file mode 100644 index 0000000..d642209 --- /dev/null +++ b/docs/discopanel/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "command": "./node_modules/.bin/astro dev", + "name": "Development server", + "request": "launch", + "type": "node-terminal" + } + ] +} diff --git a/docs/discopanel/astro.config.mjs b/docs/discopanel/astro.config.mjs new file mode 100644 index 0000000..c8e27c7 --- /dev/null +++ b/docs/discopanel/astro.config.mjs @@ -0,0 +1,40 @@ +// @ts-check +import { defineConfig } from 'astro/config'; +import starlight from '@astrojs/starlight'; + +// https://astro.build/config +export default defineConfig({ + site: 'https://docs.discopanel.app', + integrations: [ + starlight({ + title: 'DiscoPanel', + social: [ + { icon: 'github', label: 'GitHub', href: 'https://github.com/nickheyer/discopanel' }, + ], + sidebar: [ + { label: 'Introduction', slug: 'introduction' }, + { + label: 'Getting Started', + items: [ + { label: 'Docker Compose', slug: 'getting-started/docker-compose' }, + { label: 'Proxmox LXC', slug: 'getting-started/proxmox' }, + { label: 'Prebuilt Binaries', slug: 'getting-started/prebuilt-binaries' }, + { label: 'Building from Source', slug: 'getting-started/build-from-source' }, + ], + }, + { label: 'Configuration', slug: 'configuration' }, + { + label: 'Guides', + items: [ + { label: 'Keycloak Auth Setup', slug: 'guides/keycloak' }, + { label: 'Authelia Auth Setup', slug: 'guides/authelia' }, + ], + }, + { label: 'FAQ', slug: 'faq' }, + { label: 'Troubleshooting', slug: 'troubleshooting' }, + { label: 'API Reference', slug: 'api' }, + { label: 'Contributing', slug: 'contributing' }, + ], + }), + ], +}); diff --git a/docs/discopanel/package-lock.json b/docs/discopanel/package-lock.json new file mode 100644 index 0000000..173cdb9 --- /dev/null +++ b/docs/discopanel/package-lock.json @@ -0,0 +1,6851 @@ +{ + "name": "@discopanel/docs", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@discopanel/docs", + "version": "2.0.0", + "dependencies": { + "@astrojs/starlight": "^0.37.6", + "astro": "^5.6.1", + "sharp": "^0.34.2" + } + }, + "node_modules/@astrojs/compiler": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz", + "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==", + "license": "MIT" + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.5.tgz", + "integrity": "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==", + "license": "MIT" + }, + "node_modules/@astrojs/markdown-remark": { + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.10.tgz", + "integrity": "sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.7.5", + "@astrojs/prism": "3.3.0", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "shiki": "^3.19.0", + "smol-toml": "^1.5.2", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/mdx": { + "version": "4.3.13", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-4.3.13.tgz", + "integrity": "sha512-IHDHVKz0JfKBy3//52JSiyWv089b7GVSChIXLrlUOoTLWowG3wr2/8hkaEgEyd/vysvNQvGk+QhysXpJW5ve6Q==", + "license": "MIT", + "dependencies": { + "@astrojs/markdown-remark": "6.3.10", + "@mdx-js/mdx": "^3.1.1", + "acorn": "^8.15.0", + "es-module-lexer": "^1.7.0", + "estree-util-visit": "^2.0.0", + "hast-util-to-html": "^9.0.5", + "piccolore": "^0.1.3", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-smartypants": "^3.0.2", + "source-map": "^0.7.6", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.3" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + }, + "peerDependencies": { + "astro": "^5.0.0" + } + }, + "node_modules/@astrojs/prism": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz", + "integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@astrojs/sitemap": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.0.tgz", + "integrity": "sha512-+qxjUrz6Jcgh+D5VE1gKUJTA3pSthuPHe6Ao5JCxok794Lewx8hBFaWHtOnN0ntb2lfOf7gvOi9TefUswQ/ZVA==", + "license": "MIT", + "dependencies": { + "sitemap": "^8.0.2", + "stream-replace-string": "^2.0.0", + "zod": "^3.25.76" + } + }, + "node_modules/@astrojs/starlight": { + "version": "0.37.6", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.37.6.tgz", + "integrity": "sha512-wQrKwH431q+8FsLBnNQeG+R36TMtEGxTQ2AuiVpcx9APcazvL3n7wVW8mMmYyxX0POjTnxlcWPkdMGR3Yj1L+w==", + "license": "MIT", + "dependencies": { + "@astrojs/markdown-remark": "^6.3.1", + "@astrojs/mdx": "^4.2.3", + "@astrojs/sitemap": "^3.3.0", + "@pagefind/default-ui": "^1.3.0", + "@types/hast": "^3.0.4", + "@types/js-yaml": "^4.0.9", + "@types/mdast": "^4.0.4", + "astro-expressive-code": "^0.41.1", + "bcp-47": "^2.1.0", + "hast-util-from-html": "^2.0.1", + "hast-util-select": "^6.0.2", + "hast-util-to-string": "^3.0.0", + "hastscript": "^9.0.0", + "i18next": "^23.11.5", + "js-yaml": "^4.1.0", + "klona": "^2.0.6", + "magic-string": "^0.30.17", + "mdast-util-directive": "^3.0.0", + "mdast-util-to-markdown": "^2.1.0", + "mdast-util-to-string": "^4.0.0", + "pagefind": "^1.3.0", + "rehype": "^13.0.1", + "rehype-format": "^5.0.0", + "remark-directive": "^3.0.0", + "ultrahtml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.2" + }, + "peerDependencies": { + "astro": "^5.5.0" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz", + "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.2.0", + "debug": "^4.4.0", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "is-docker": "^3.0.0", + "is-wsl": "^3.1.0", + "which-pm-runs": "^1.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", + "integrity": "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@expressive-code/core": { + "version": "0.41.6", + "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.41.6.tgz", + "integrity": "sha512-FvJQP+hG0jWi/FLBSmvHInDqWR7jNANp9PUDjdMqSshHb0y7sxx3vHuoOr6SgXjWw+MGLqorZyPQ0aAlHEok6g==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.0.4", + "hast-util-select": "^6.0.2", + "hast-util-to-html": "^9.0.1", + "hast-util-to-text": "^4.0.1", + "hastscript": "^9.0.0", + "postcss": "^8.4.38", + "postcss-nested": "^6.0.1", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.1" + } + }, + "node_modules/@expressive-code/plugin-frames": { + "version": "0.41.6", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.41.6.tgz", + "integrity": "sha512-d+hkSYXIQot6fmYnOmWAM+7TNWRv/dhfjMsNq+mIZz8Tb4mPHOcgcfZeEM5dV9TDL0ioQNvtcqQNuzA1sRPjxg==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.41.6" + } + }, + "node_modules/@expressive-code/plugin-shiki": { + "version": "0.41.6", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.41.6.tgz", + "integrity": "sha512-Y6zmKBmsIUtWTzdefqlzm/h9Zz0Rc4gNdt2GTIH7fhHH2I9+lDYCa27BDwuBhjqcos6uK81Aca9dLUC4wzN+ng==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.41.6", + "shiki": "^3.2.2" + } + }, + "node_modules/@expressive-code/plugin-text-markers": { + "version": "0.41.6", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.41.6.tgz", + "integrity": "sha512-PBFa1wGyYzRExMDzBmAWC6/kdfG1oLn4pLpBeTfIRrALPjcGA/59HP3e7q9J0Smk4pC7U+lWkA2LHR8FYV8U7Q==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.41.6" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@mdx-js/mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", + "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-scope": "^1.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "recma-build-jsx": "^1.0.0", + "recma-jsx": "^1.0.0", + "recma-stringify": "^1.0.0", + "rehype-recma": "^1.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@pagefind/darwin-arm64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.4.0.tgz", + "integrity": "sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/darwin-x64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.4.0.tgz", + "integrity": "sha512-e7JPIS6L9/cJfow+/IAqknsGqEPjJnVXGjpGm25bnq+NPdoD3c/7fAwr1OXkG4Ocjx6ZGSCijXEV4ryMcH2E3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/default-ui": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/default-ui/-/default-ui-1.4.0.tgz", + "integrity": "sha512-wie82VWn3cnGEdIjh4YwNESyS1G6vRHwL6cNjy9CFgNnWW/PGRjsLq300xjVH5sfPFK3iK36UxvIBymtQIEiSQ==", + "license": "MIT" + }, + "node_modules/@pagefind/freebsd-x64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.4.0.tgz", + "integrity": "sha512-WcJVypXSZ+9HpiqZjFXMUobfFfZZ6NzIYtkhQ9eOhZrQpeY5uQFqNWLCk7w9RkMUwBv1HAMDW3YJQl/8OqsV0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@pagefind/linux-arm64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.4.0.tgz", + "integrity": "sha512-PIt8dkqt4W06KGmQjONw7EZbhDF+uXI7i0XtRLN1vjCUxM9vGPdtJc2mUyVPevjomrGz5M86M8bqTr6cgDp1Uw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/linux-x64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.4.0.tgz", + "integrity": "sha512-z4oddcWwQ0UHrTHR8psLnVlz6USGJ/eOlDPTDYZ4cI8TK8PgwRUPQZp9D2iJPNIPcS6Qx/E4TebjuGJOyK8Mmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/windows-x64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.4.0.tgz", + "integrity": "sha512-NkT+YAdgS2FPCn8mIA9bQhiBs+xmniMGq1LFPDhcFn0+2yIUEiIG06t7bsZlhdjknEQRTSdT7YitP6fC5qwP0g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.22.0.tgz", + "integrity": "sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.22.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.22.0.tgz", + "integrity": "sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.22.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.22.0.tgz", + "integrity": "sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.22.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.22.0.tgz", + "integrity": "sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.22.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.22.0.tgz", + "integrity": "sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.22.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.22.0.tgz", + "integrity": "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/astro": { + "version": "5.17.3", + "resolved": "https://registry.npmjs.org/astro/-/astro-5.17.3.tgz", + "integrity": "sha512-69dcfPe8LsHzklwj+hl+vunWUbpMB6pmg35mACjetxbJeUNNys90JaBM8ZiwsPK689SAj/4Zqb1ayaANls9/MA==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^2.13.0", + "@astrojs/internal-helpers": "0.7.5", + "@astrojs/markdown-remark": "6.3.10", + "@astrojs/telemetry": "3.3.0", + "@capsizecss/unpack": "^4.0.0", + "@oslojs/encoding": "^1.1.0", + "@rollup/pluginutils": "^5.3.0", + "acorn": "^8.15.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "boxen": "8.0.1", + "ci-info": "^4.3.1", + "clsx": "^2.1.1", + "common-ancestor-path": "^1.0.1", + "cookie": "^1.1.1", + "cssesc": "^3.0.0", + "debug": "^4.4.3", + "deterministic-object-hash": "^2.0.2", + "devalue": "^5.6.2", + "diff": "^8.0.3", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "es-module-lexer": "^1.7.0", + "esbuild": "^0.27.3", + "estree-walker": "^3.0.3", + "flattie": "^1.1.1", + "fontace": "~0.4.0", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "magic-string": "^0.30.21", + "magicast": "^0.5.1", + "mrmime": "^2.0.1", + "neotraverse": "^0.6.18", + "p-limit": "^6.2.0", + "p-queue": "^8.1.1", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.3", + "prompts": "^2.4.2", + "rehype": "^13.0.2", + "semver": "^7.7.3", + "shiki": "^3.21.0", + "smol-toml": "^1.6.0", + "svgo": "^4.0.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tsconfck": "^3.1.6", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.3", + "unist-util-visit": "^5.0.0", + "unstorage": "^1.17.4", + "vfile": "^6.0.3", + "vite": "^6.4.1", + "vitefu": "^1.1.1", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^21.1.1", + "yocto-spinner": "^0.2.3", + "zod": "^3.25.76", + "zod-to-json-schema": "^3.25.1", + "zod-to-ts": "^1.2.0" + }, + "bin": { + "astro": "astro.js" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0" + } + }, + "node_modules/astro-expressive-code": { + "version": "0.41.6", + "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.41.6.tgz", + "integrity": "sha512-l47tb1uhmVIebHUkw+HEPtU/av0G4O8Q34g2cbkPvC7/e9ZhANcjUUciKt9Hp6gSVDdIuXBBLwJQn2LkeGMOAw==", + "license": "MIT", + "dependencies": { + "rehype-expressive-code": "^0.41.6" + }, + "peerDependencies": { + "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "license": "MIT" + }, + "node_modules/bcp-47": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.0.tgz", + "integrity": "sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47-match": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", + "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", + "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", + "license": "ISC" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-selector-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz", + "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/deterministic-object-hash": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz", + "integrity": "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==", + "license": "MIT", + "dependencies": { + "base-64": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/devalue": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", + "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/direction": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/direction/-/direction-2.0.1.tgz", + "integrity": "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==", + "license": "MIT", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "license": "MIT" + }, + "node_modules/esast-util-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", + "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esast-util-from-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", + "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "acorn": "^8.0.0", + "esast-util-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", + "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", + "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-scope": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz", + "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", + "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expressive-code": { + "version": "0.41.6", + "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.41.6.tgz", + "integrity": "sha512-W/5+IQbrpCIM5KGLjO35wlp1NCwDOOVQb+PAvzEoGkW1xjGM807ZGfBKptNWH6UECvt6qgmLyWolCMYKh7eQmA==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.41.6", + "@expressive-code/plugin-frames": "^0.41.6", + "@expressive-code/plugin-shiki": "^0.41.6", + "@expressive-code/plugin-text-markers": "^0.41.6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.2.tgz", + "integrity": "sha512-piJxbLnkD9Xcyi7dWJRnqszEURixe7CrF/efBfbffe2DPyabmuIuqraruY8cXTs19QoM8VJzx47BDRVNXETM7Q==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.5.tgz", + "integrity": "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.2", + "crossws": "^0.3.5", + "defu": "^6.1.4", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-format": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hast-util-format/-/hast-util-format-1.1.0.tgz", + "integrity": "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-minify-whitespace": "^1.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "html-whitespace-sensitive-tag-names": "^3.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-body-ok-link": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", + "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-minify-whitespace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", + "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-select": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz", + "integrity": "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "bcp-47-match": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "css-selector-parser": "^3.0.0", + "devlop": "^1.0.0", + "direction": "^2.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "nth-check": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-estree": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", + "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz", + "integrity": "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/i18next": { + "version": "23.16.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", + "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", + "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-directive": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", + "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-3.0.2.tgz", + "integrity": "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", + "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", + "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", + "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz", + "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/pagefind": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.4.0.tgz", + "integrity": "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g==", + "license": "MIT", + "bin": { + "pagefind": "lib/runner/bin.cjs" + }, + "optionalDependencies": { + "@pagefind/darwin-arm64": "1.4.0", + "@pagefind/darwin-x64": "1.4.0", + "@pagefind/freebsd-x64": "1.4.0", + "@pagefind/linux-arm64": "1.4.0", + "@pagefind/linux-x64": "1.4.0", + "@pagefind/windows-x64": "1.4.0" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recma-build-jsx": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", + "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-jsx": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", + "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "license": "MIT", + "dependencies": { + "acorn-jsx": "^5.0.0", + "estree-util-to-js": "^2.0.0", + "recma-parse": "^1.0.0", + "recma-stringify": "^1.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/recma-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", + "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "esast-util-from-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", + "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-to-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-expressive-code": { + "version": "0.41.6", + "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.41.6.tgz", + "integrity": "sha512-aBMX8kxPtjmDSFUdZlAWJkMvsQ4ZMASfee90JWIAV8tweltXLzkWC3q++43ToTelI8ac5iC0B3/S/Cl4Ql1y2g==", + "license": "MIT", + "dependencies": { + "expressive-code": "^0.41.6" + } + }, + "node_modules/rehype-format": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz", + "integrity": "sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-format": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-recma": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", + "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "hast-util-to-estree": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-directive": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.1.tgz", + "integrity": "sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-directive": "^3.0.0", + "micromark-extension-directive": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz", + "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shiki": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.22.0.tgz", + "integrity": "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.22.0", + "@shikijs/engine-javascript": "3.22.0", + "@shikijs/engine-oniguruma": "3.22.0", + "@shikijs/langs": "3.22.0", + "@shikijs/themes": "3.22.0", + "@shikijs/types": "3.22.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/sitemap": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-8.0.2.tgz", + "integrity": "sha512-LwktpJcyZDoa0IL6KT++lQ53pbSrx2c9ge41/SeLTyqy2XUNA6uR4+P9u5IVo5lPeL2arAcOKn1aZAxoYbCKlQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^17.0.5", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.4.1" + }, + "bin": { + "sitemap": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0", + "npm": ">=6.0.0" + } + }, + "node_modules/sitemap/node_modules/@types/node": { + "version": "17.0.45", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", + "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stream-replace-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz", + "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/svgo": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz", + "integrity": "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.4.1" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", + "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", + "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ofetch": "^1.5.1", + "ohash": "^2.0.11" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.4.tgz", + "integrity": "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.5", + "lru-cache": "^11.2.0", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-spinner": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-0.2.3.tgz", + "integrity": "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, + "node_modules/zod-to-ts": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/zod-to-ts/-/zod-to-ts-1.2.0.tgz", + "integrity": "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==", + "peerDependencies": { + "typescript": "^4.9.4 || ^5.0.2", + "zod": "^3" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/docs/discopanel/package.json b/docs/discopanel/package.json new file mode 100644 index 0000000..b337495 --- /dev/null +++ b/docs/discopanel/package.json @@ -0,0 +1,17 @@ +{ + "name": "@discopanel/docs", + "type": "module", + "version": "2.0.0", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/starlight": "^0.37.6", + "astro": "^5.6.1", + "sharp": "^0.34.2" + } +} \ No newline at end of file diff --git a/docs/discopanel/public/api-reference.html b/docs/discopanel/public/api-reference.html new file mode 100644 index 0000000..f1b7e86 --- /dev/null +++ b/docs/discopanel/public/api-reference.html @@ -0,0 +1,32 @@ + + + + + + + + + +
+ + + diff --git a/docs/discopanel/public/discopanel_logo_transparent_512.png b/docs/discopanel/public/discopanel_logo_transparent_512.png new file mode 100644 index 0000000..629501a Binary files /dev/null and b/docs/discopanel/public/discopanel_logo_transparent_512.png differ diff --git a/docs/discopanel/public/favicon.png b/docs/discopanel/public/favicon.png new file mode 100644 index 0000000..270c0b6 Binary files /dev/null and b/docs/discopanel/public/favicon.png differ diff --git a/docs/discopanel/public/mc_diamond_ore_block_texture.png b/docs/discopanel/public/mc_diamond_ore_block_texture.png new file mode 100644 index 0000000..b61b129 Binary files /dev/null and b/docs/discopanel/public/mc_diamond_ore_block_texture.png differ diff --git a/docs/discopanel/public/mc_dirt_block_texture.png b/docs/discopanel/public/mc_dirt_block_texture.png new file mode 100644 index 0000000..ef900e2 Binary files /dev/null and b/docs/discopanel/public/mc_dirt_block_texture.png differ diff --git a/docs/discopanel/public/mc_lava_block_texture.png b/docs/discopanel/public/mc_lava_block_texture.png new file mode 100644 index 0000000..c68650c Binary files /dev/null and b/docs/discopanel/public/mc_lava_block_texture.png differ diff --git a/docs/discopanel/public/mc_netherrack_block_texture.png b/docs/discopanel/public/mc_netherrack_block_texture.png new file mode 100644 index 0000000..c8b18fe Binary files /dev/null and b/docs/discopanel/public/mc_netherrack_block_texture.png differ diff --git a/docs/discopanel/public/mc_obsidian_block_texture.png b/docs/discopanel/public/mc_obsidian_block_texture.png new file mode 100644 index 0000000..a38c070 Binary files /dev/null and b/docs/discopanel/public/mc_obsidian_block_texture.png differ diff --git a/docs/discopanel/src/content.config.ts b/docs/discopanel/src/content.config.ts new file mode 100644 index 0000000..d9ee8c9 --- /dev/null +++ b/docs/discopanel/src/content.config.ts @@ -0,0 +1,7 @@ +import { defineCollection } from 'astro:content'; +import { docsLoader } from '@astrojs/starlight/loaders'; +import { docsSchema } from '@astrojs/starlight/schema'; + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), +}; diff --git a/docs/discopanel/src/content/docs/api.mdx b/docs/discopanel/src/content/docs/api.mdx new file mode 100644 index 0000000..6330114 --- /dev/null +++ b/docs/discopanel/src/content/docs/api.mdx @@ -0,0 +1,30 @@ +--- +title: API Reference +description: Interactive DiscoPanel API documentation powered by Scalar. +tableOfContents: false +--- + +This spec is auto-generated from the protobuf definitions and stays in sync with the latest release. + + + +
+ +
diff --git a/docs/discopanel/src/content/docs/configuration.mdx b/docs/discopanel/src/content/docs/configuration.mdx new file mode 100644 index 0000000..22cae58 --- /dev/null +++ b/docs/discopanel/src/content/docs/configuration.mdx @@ -0,0 +1,41 @@ +--- +title: Configuration +description: DiscoPanel settings, config files, and environment variables. +--- + +import { Code } from '@astrojs/starlight/components'; +import configExample from '../../../../../config.example.yaml?raw'; + +DiscoPanel can be configured via a YAML config file, environment variables, or both. Environment variables take precedence over the config file. + +For Minecraft server-specific configuration (server.properties, JVM flags, mod loader settings, etc.), see the [itzg/minecraft-server docs](https://docker-minecraft-server.readthedocs.io/). + +## Config file + +DiscoPanel looks for `config.yaml` in these locations (in order): + +1. Path passed via `-config` flag +2. Current working directory (`./`) +3. `./config/` +4. `/etc/discopanel/` + +If no config file is found, defaults are used. + +## Environment variables + +Every config option can be set via an environment variable with the `DISCOPANEL_` prefix. Nested keys are separated by underscores: + +```yaml +server: + port: "8080" +``` + +is equivalent to: + +```bash +DISCOPANEL_SERVER_PORT="8080" +``` + +## All options + + diff --git a/docs/discopanel/src/content/docs/contributing.md b/docs/discopanel/src/content/docs/contributing.md new file mode 100644 index 0000000..5d86ddc --- /dev/null +++ b/docs/discopanel/src/content/docs/contributing.md @@ -0,0 +1,8 @@ +--- +title: Contributing +description: Guidelines for contributing to DiscoPanel. +--- + +Guidelines for developers making pull requests to DiscoPanel. + +*This section is a work in progress.* diff --git a/docs/discopanel/src/content/docs/faq.md b/docs/discopanel/src/content/docs/faq.md new file mode 100644 index 0000000..b551210 --- /dev/null +++ b/docs/discopanel/src/content/docs/faq.md @@ -0,0 +1,8 @@ +--- +title: FAQ +description: Frequently asked questions. +--- + +Answers to the questions that get asked over and over. + +*This section is a work in progress.* diff --git a/docs/discopanel/src/content/docs/getting-started/build-from-source.mdx b/docs/discopanel/src/content/docs/getting-started/build-from-source.mdx new file mode 100644 index 0000000..056dbb0 --- /dev/null +++ b/docs/discopanel/src/content/docs/getting-started/build-from-source.mdx @@ -0,0 +1,57 @@ +--- +title: Building from Source +description: Build DiscoPanel from source code. +--- + +## Prerequisites + +DiscoPanel manages Minecraft servers as Docker containers, so Docker is required both at build time and runtime. + +- **Go** 1.24+ +- **Node.js** 22+ +- **Docker** (also used to run [buf](https://buf.build/) for protobuf code generation) +- **Make** + +## Build steps + +```bash +# Clone the repo +git clone https://github.com/nickheyer/discopanel.git +cd discopanel + +# Generate protobuf code (Go + TypeScript) via Docker buf +make gen + +# Install frontend dependencies and build +cd web/discopanel && npm install && npm run build && cd ../.. + +# Build the Go binary with embedded frontend +go build -o discopanel cmd/discopanel/main.go +``` + +The `//go:embed` directives bake the frontend build into the binary so it serves the UI without a separate web server. + +## Running + +```bash +# Using default config (./config.yaml if it exists, otherwise defaults) +./discopanel + +# Or point to a specific config file +./discopanel -config /path/to/config.yaml +``` + +All config options can also be set via environment variables with the `DISCOPANEL_` prefix. For example, `server.port` becomes `DISCOPANEL_SERVER_PORT`. Environment variables take precedence over the config file. + +:::tip[Host path mapping] +When running the binary directly on a host (not in a Docker container), do **not** set `DISCOPANEL_DATA_DIR` or `DISCOPANEL_HOST_DATA_PATH` — those are only for container path translation. + +To change where DiscoPanel stores its database and server data, set `DISCOPANEL_STORAGE_DATA_DIR` or configure it in your config file: + +```yaml +storage: + data_dir: /your/path # Default: ./data (relative to working directory) +``` +::: + +For all available configuration options, see [Configuration](/configuration/). diff --git a/docs/discopanel/src/content/docs/getting-started/docker-compose.mdx b/docs/discopanel/src/content/docs/getting-started/docker-compose.mdx new file mode 100644 index 0000000..6eb1789 --- /dev/null +++ b/docs/discopanel/src/content/docs/getting-started/docker-compose.mdx @@ -0,0 +1,47 @@ +--- +title: Docker Compose +description: Deploy DiscoPanel using Docker Compose. +--- + +import { Code } from '@astrojs/starlight/components'; +import dockerCompose from '../../../../../../docker-compose.yml?raw'; + +## Prerequisites + +DiscoPanel manages Minecraft servers as Docker containers, so Docker is required. + +- [Docker Engine](https://docs.docker.com/engine/install/) with Docker Compose + +## Compose file + +The recommended way to run DiscoPanel. Create a `docker-compose.yml` or use the one from the repo: + + + +Spin up your service: + +```bash +docker compose up -d +``` + +## Volume paths + +DiscoPanel creates Docker bind mounts for each managed Minecraft server. When DiscoPanel runs inside a container, it needs to know both the container-internal path and the corresponding host path to set up those mounts correctly. + +| Variable | Purpose | Example | +|---|---|---| +| `DISCOPANEL_HOST_DATA_PATH` | The host path that `DISCOPANEL_DATA_DIR` is mounted from | `/opt/discopanel/data` | +| `DISCOPANEL_DATA_DIR` | Where DiscoPanel reads/writes data inside the container — you can usually leave this as the default | `/app/data` | + +These **must** correspond to the same volume entry. If your compose file maps `/srv/minecraft/data:/app/data`, then set `DISCOPANEL_HOST_DATA_PATH=/srv/minecraft/data`. + +:::caution[SELinux] +On Fedora, RHEL, CentOS, etc., append `:z` to volume mounts: +```yaml +- /var/run/docker.sock:/var/run/docker.sock:z +``` +::: + +Once running, open **http://\:8080** and create your admin account. + +For all available configuration options, see [Configuration](/configuration/). diff --git a/docs/discopanel/src/content/docs/getting-started/prebuilt-binaries.mdx b/docs/discopanel/src/content/docs/getting-started/prebuilt-binaries.mdx new file mode 100644 index 0000000..68360d9 --- /dev/null +++ b/docs/discopanel/src/content/docs/getting-started/prebuilt-binaries.mdx @@ -0,0 +1,53 @@ +--- +title: Prebuilt Binaries +description: Install DiscoPanel from prebuilt release binaries. +--- + +## Prerequisites + +DiscoPanel manages Minecraft servers as Docker containers, so Docker is required. + +- [Docker Engine](https://docs.docker.com/engine/install/) + +## Download + +Grab a release from the [releases page](https://github.com/nickheyer/discopanel/releases). Packages are available for each platform: + +``` +discopanel-darwin-amd64.tar.gz +discopanel-darwin-arm64.tar.gz +discopanel-linux-amd64.tar.gz +discopanel-linux-arm64.tar.gz +discopanel-windows-amd64.exe.zip +``` + +:::note +Darwin is the platform name for macOS. Only Linux is fully supported and tested — Windows installations may have issues. +::: + +Extract the archive and run it: + +## Running + +```bash +# Using default config (./config.yaml if it exists, otherwise defaults) +./discopanel + +# Or point to a specific config file +./discopanel -config /path/to/config.yaml +``` + +All config options can also be set via environment variables with the `DISCOPANEL_` prefix. For example, `server.port` becomes `DISCOPANEL_SERVER_PORT`. Environment variables take precedence over the config file. + +:::tip[Host path mapping] +When running the binary directly on a host (not in a Docker container), do **not** set `DISCOPANEL_DATA_DIR` or `DISCOPANEL_HOST_DATA_PATH` — those are only for container path translation. + +To change where DiscoPanel stores its database and server data, set `DISCOPANEL_STORAGE_DATA_DIR` or configure it in your config file: + +```yaml +storage: + data_dir: /your/path # Default: ./data (relative to working directory) +``` +::: + +For all available configuration options, see [Configuration](/configuration/). diff --git a/docs/discopanel/src/content/docs/getting-started/proxmox.mdx b/docs/discopanel/src/content/docs/getting-started/proxmox.mdx new file mode 100644 index 0000000..e84c18c --- /dev/null +++ b/docs/discopanel/src/content/docs/getting-started/proxmox.mdx @@ -0,0 +1,36 @@ +--- +title: Proxmox LXC +description: Deploy DiscoPanel in a Proxmox LXC container using the community helper script. +--- + +## Prerequisites + +- A [Proxmox VE](https://www.proxmox.com/en/proxmox-virtual-environment/overview) host + +## Installation + +The [Proxmox VE Community Scripts](https://community-scripts.github.io/ProxmoxVE/) project maintains a [DiscoPanel helper script](https://community-scripts.github.io/ProxmoxVE/scripts?id=discopanel) that creates a Debian 13 LXC container with everything pre-installed (Docker, Go, Node.js, DiscoPanel built from source) and managed as a systemd service. + +From your **Proxmox host shell**: + +```bash +bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/discopanel.sh)" +``` + +The interactive wizard lets you adjust the default container resources. Set these based on what you expect your servers to need. + +## Sizing guidance + +**CPU** — A single Minecraft server (vanilla or modded) rarely exceeds 400% (4 cores). While up to 4 cores per server is recommended for optimal performance, you can start with the default of `4` and increase as needed. + +**RAM** — Vanilla servers may only need a few GB, but heavily modded servers average 10 GB+ per instance, especially with multiple players. You can safely allocate most of your available RAM — Proxmox doesn't strictly reserve it, so other LXCs and the host OS can still use whatever DiscoPanel isn't actively consuming. + +**Disk** — Entirely depends on what you plan to run and for how long. 40–80 GB is a reasonable starting point and can be expanded later. + +## Paths + +Since DiscoPanel runs directly on the host here (not inside a Docker container), file paths are straightforward — `DISCOPANEL_DATA_DIR` and `DISCOPANEL_HOST_DATA_PATH` should be the same value. The default `./data` relative to `/opt/discopanel` works out of the box. + +Once complete, DiscoPanel is accessible at **http://\:8080**. + +For all available configuration options, see [Configuration](/configuration/). diff --git a/docs/discopanel/src/content/docs/guides/authelia.mdx b/docs/discopanel/src/content/docs/guides/authelia.mdx new file mode 100644 index 0000000..298b0d6 --- /dev/null +++ b/docs/discopanel/src/content/docs/guides/authelia.mdx @@ -0,0 +1,89 @@ +--- +title: Authelia (OIDC) +description: Set up DiscoPanel with Authelia as an OpenID Connect identity provider. +--- + +import { Code, Aside } from '@astrojs/starlight/components'; +import composeFile from '../../../../../../oidc/authelia/docker-compose.yaml?raw'; +import configYml from '../../../../../../oidc/authelia/config/configuration.yml?raw'; +import usersYml from '../../../../../../oidc/authelia/config/users_database.yml?raw'; + +This guide walks through running DiscoPanel with [Authelia](https://www.authelia.com/) as an OIDC identity provider using a ready-made Docker Compose stack. Authelia provides a file-based user store, so no external database is needed. + + + +## Prerequisites + +- Docker and Docker Compose + +## Docker Compose + +Clone the repo and navigate to the `oidc/authelia/` directory, then start the stack: + +```bash +cd oidc/authelia +docker compose up -d +``` + + + +### Key environment variables + +| Variable | Purpose | +|---|---| +| `DISCOPANEL_AUTH_OIDC_ENABLED` | Enables OIDC authentication | +| `DISCOPANEL_AUTH_OIDC_ISSUER_URI` | Authelia's OIDC issuer URL — must match the `session.cookies` domain in `configuration.yml` | +| `DISCOPANEL_AUTH_OIDC_CLIENT_ID` | Must match the `client_id` in `configuration.yml` | +| `DISCOPANEL_AUTH_OIDC_CLIENT_SECRET` | The **plaintext** secret — the hashed version is stored in `configuration.yml` | +| `DISCOPANEL_AUTH_OIDC_REDIRECT_URL` | The callback URL — update `localhost:8080` to your public domain | +| `DISCOPANEL_AUTH_OIDC_ROLE_CLAIM` | Set to `groups` to read Authelia group membership as DiscoPanel roles | +| `DISCOPANEL_AUTH_OIDC_SKIP_TLS_VERIFY` | Set to `true` because the included TLS certs are self-signed — **remove this when using real certs** | + +## Authelia configuration + + + +### Notable sections + +- **`identity_providers.oidc.clients`**: the DiscoPanel OIDC client — `client_secret` is a PBKDF2-SHA512 hash of the plaintext secret +- **`claims_policies.discopanel`**: puts `groups` directly in the ID token so DiscoPanel can read roles without a separate UserInfo call +- **`identity_providers.oidc.jwks`**: an RSA key pair used for signing tokens — generate your own for production +- **`session.cookies.domain`**: set to `traefik.me` for development — change to your actual domain + +## User database + +Authelia stores users in a YAML file. The default config includes a single admin user. + + + +### Generating password hashes + +To add or change users, generate an Argon2id hash: + +```bash +docker run --rm authelia/authelia:latest \ + authelia crypto hash generate argon2 \ + --password 'your-password-here' +``` + +## Default credentials + +| Service | URL | Username | Password | +|---|---|---|---| +| DiscoPanel | [http://localhost:8080](http://localhost:8080) | — | Log in via OIDC | +| Authelia login portal | [https://authelia.traefik.me:9091](https://authelia.traefik.me:9091) | `admin` | `admin` | + +## Production notes + + + +- **Use real TLS certificates**: replace `tls.crt` and `tls.key`, then remove `DISCOPANEL_AUTH_OIDC_SKIP_TLS_VERIFY` +- **Change all secrets**: the HMAC secret, session secret, storage encryption key, JWT reset secret, and the OIDC client secret (both the hash in `configuration.yml` and the plaintext in the compose file) +- **Generate new JWKS keys**: replace the RSA key pair in `configuration.yml` +- **Update the session domain**: change `traefik.me` to your actual domain +- **Update redirect URIs**: change `localhost` entries to your actual domain +- **Disable local auth** (optional): set `DISCOPANEL_AUTH_LOCAL_ENABLED=false` if you want OIDC-only login diff --git a/docs/discopanel/src/content/docs/guides/keycloak.mdx b/docs/discopanel/src/content/docs/guides/keycloak.mdx new file mode 100644 index 0000000..a6ac186 --- /dev/null +++ b/docs/discopanel/src/content/docs/guides/keycloak.mdx @@ -0,0 +1,69 @@ +--- +title: Keycloak (OIDC) +description: Set up DiscoPanel with Keycloak as an OpenID Connect identity provider. +--- + +import { Code, Aside } from '@astrojs/starlight/components'; +import composeFile from '../../../../../../oidc/keycloak/docker-compose.yaml?raw'; +import realmJson from '../../../../../../oidc/keycloak/config/realm.json?raw'; + +This guide walks through running DiscoPanel with [Keycloak](https://www.keycloak.org/) as an OIDC identity provider using a ready-made Docker Compose stack. The included realm config pre-creates a client, roles, groups, protocol mappers, and a default admin user so everything works out of the box. + +## Prerequisites + +- Docker and Docker Compose + +## Docker Compose + +Clone the repo and navigate to the `oidc/keycloak/` directory, then start the stack: + +```bash +cd oidc/keycloak +docker compose up -d +``` + +Keycloak takes 30–60 seconds to start. DiscoPanel waits for it via a health check. + + + +### Key environment variables + +| Variable | Purpose | +|---|---| +| `DISCOPANEL_AUTH_OIDC_ENABLED` | Enables OIDC authentication | +| `DISCOPANEL_AUTH_OIDC_ISSUER_URI` | Keycloak realm URL — change if hosting on another machine or using a different realm name | +| `DISCOPANEL_AUTH_OIDC_CLIENT_ID` | Must match the `clientId` in the realm config | +| `DISCOPANEL_AUTH_OIDC_CLIENT_SECRET` | Must match the `secret` in the realm config — **change this for production** | +| `DISCOPANEL_AUTH_OIDC_REDIRECT_URL` | The callback URL — update `localhost:8080` to your public domain | +| `DISCOPANEL_AUTH_OIDC_ROLE_CLAIM` | Set to `groups` to read Keycloak group membership as DiscoPanel roles | + +## Realm configuration + +The included realm JSON is imported automatically on first start via `--import-realm`. It configures: + +- **Client** (`discopanel`): confidential client with standard (authorization code) flow +- **Roles**: `admin` and `user` realm roles +- **Groups**: `admin` and `user` groups mapped to their respective roles — new users are added to the `user` group by default +- **Protocol mappers**: a `groups` mapper that puts group membership into the `groups` claim on all tokens, and a `realm-roles` mapper for the `roles` claim +- **Default user**: `admin` / `admin` with both `admin` and `user` groups + + + +## Default credentials + +| Service | URL | Username | Password | +|---|---|---|---| +| DiscoPanel | [http://localhost:8080](http://localhost:8080) | — | Log in via OIDC | +| Keycloak Admin Console | [http://localhost:8180/admin](http://localhost:8180/admin) | `admin` | `admin` | +| Keycloak OIDC user | — | `admin` | `admin` | + +## Production notes + + + +- **Change all secrets**: `DISCOPANEL_AUTH_OIDC_CLIENT_SECRET`, `KC_BOOTSTRAP_ADMIN_PASSWORD`, the client `secret` in realm.json, and the Postgres password +- **Enable TLS**: put Keycloak behind a reverse proxy with a real certificate and update `DISCOPANEL_AUTH_OIDC_ISSUER_URI` to `https://` +- **Update redirect URIs**: change `localhost` entries in both the compose file and realm.json to your actual domain +- **Disable local auth** (optional): set `DISCOPANEL_AUTH_LOCAL_ENABLED=false` if you want OIDC-only login diff --git a/docs/discopanel/src/content/docs/index.mdx b/docs/discopanel/src/content/docs/index.mdx new file mode 100644 index 0000000..96f10f1 --- /dev/null +++ b/docs/discopanel/src/content/docs/index.mdx @@ -0,0 +1,148 @@ +--- +title: DiscoPanel +description: Minecraft server management, simplified. +template: splash +hero: + tagline: A self-hosted Minecraft server + proxy + modpack manager. Docker-powered, easy to deploy, and it actually works. + image: + html: | + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ actions: + - text: Get Started + link: /introduction/ + icon: right-arrow + variant: primary + - text: View on GitHub + link: https://github.com/nickheyer/discopanel + icon: external + variant: minimal +--- diff --git a/docs/discopanel/src/content/docs/introduction.md b/docs/discopanel/src/content/docs/introduction.md new file mode 100644 index 0000000..cc99183 --- /dev/null +++ b/docs/discopanel/src/content/docs/introduction.md @@ -0,0 +1,8 @@ +--- +title: Introduction +description: What DiscoPanel is and what it does. +--- + +DiscoPanel is a self-hosted, Docker-powered Minecraft server manager. It handles server creation, modpack installation, proxy routing, backups, and multi-user access control through a web interface. + +For a full feature overview, see the [project README](https://github.com/nickheyer/discopanel). diff --git a/docs/discopanel/src/content/docs/troubleshooting.md b/docs/discopanel/src/content/docs/troubleshooting.md new file mode 100644 index 0000000..a7d9cd4 --- /dev/null +++ b/docs/discopanel/src/content/docs/troubleshooting.md @@ -0,0 +1,19 @@ +--- +title: Troubleshooting +description: Common issues and how to report bugs. +--- + +Common errors, known quirks (like CurseForge API keys randomly invalidating), and steps to try before reporting an issue. + +### Reporting Issues + +Before opening a GitHub issue: + +1. Check this page and the [FAQ](/faq/) first +2. Try the latest version of DiscoPanel +3. Include your Docker/compose version, DiscoPanel version, and relevant logs +4. Open an issue at [github.com/nickheyer/discopanel/issues](https://github.com/nickheyer/discopanel/issues) + +For quicker help, ask in the [Discord](https://discord.gg/6Z9yKTbsrP). + +*This section is a work in progress.* diff --git a/docs/discopanel/tsconfig.json b/docs/discopanel/tsconfig.json new file mode 100644 index 0000000..425deb5 --- /dev/null +++ b/docs/discopanel/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "./node_modules/astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/go.mod b/go.mod index 23ebd1d..ac37945 100644 --- a/go.mod +++ b/go.mod @@ -1,25 +1,65 @@ module github.com/nickheyer/discopanel -go 1.24.5 +go 1.24.6 require ( connectrpc.com/connect v1.19.1 connectrpc.com/grpcreflect v1.3.0 github.com/arkady-emelyanov/go-shellparse v1.0.3 + github.com/casbin/casbin/v3 v3.8.1 + github.com/casbin/gorm-adapter/v3 v3.41.0 + github.com/coreos/go-oidc/v3 v3.17.0 github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.6.0 + github.com/go-gormigrate/gormigrate/v2 v2.1.5 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/gnostic v0.7.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.1 github.com/mholt/archives v0.1.5 github.com/robfig/cron/v3 v3.0.1 github.com/spf13/viper v1.20.1 - golang.org/x/crypto v0.40.0 + golang.org/x/crypto v0.46.0 + golang.org/x/oauth2 v0.35.0 google.golang.org/protobuf v1.36.10 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/sqlite v1.6.0 - gorm.io/gorm v1.30.1 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/casbin/govaluate v1.10.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.22.0 // indirect + github.com/glebarez/sqlite v1.11.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/microsoft/go-mssqldb v1.9.5 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/shopspring/decimal v1.4.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect + golang.org/x/sync v0.19.0 // indirect + gorm.io/driver/mysql v1.6.0 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/driver/sqlserver v1.6.3 // indirect + gorm.io/plugin/dbresolver v1.6.2 // indirect + modernc.org/libc v1.67.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.42.2 // indirect ) require ( @@ -39,7 +79,7 @@ require ( github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -75,9 +115,9 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect - golang.org/x/net v0.41.0 - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/net v0.47.0 + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect diff --git a/go.sum b/go.sum index e7782bc..5fec6c5 100644 --- a/go.sum +++ b/go.sum @@ -19,8 +19,34 @@ connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4 connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro= @@ -31,12 +57,22 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/arkady-emelyanov/go-shellparse v1.0.3 h1:UrfVk/sGFefGG/1m4gHR46L9ZGNaTGTWQjO7g2iHhQ8= github.com/arkady-emelyanov/go-shellparse v1.0.3/go.mod h1:s00S9U8dfIEt/+dY39VsVNzggIeNZ193md1+vjF/Jeg= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= +github.com/casbin/casbin/v3 v3.8.1 h1:D4dEY4knePPR4YgNP5WZtWNaOxD0UK0LpPy9+zxtBwo= +github.com/casbin/casbin/v3 v3.8.1/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8= +github.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM= +github.com/casbin/gorm-adapter/v3 v3.41.0/go.mod h1:BQZRJhwUnwMpI+pT2m7/cUJwXxrHfzpBpPcNTyMGeGA= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -50,11 +86,16 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -64,6 +105,8 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= +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/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -72,17 +115,34 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8= +github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -97,31 +157,57 @@ github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/gnostic v0.7.1 h1:t5Kc7j/8kYr8t2u11rykRrPPovlEMG4+xdc/SpekATs= +github.com/google/gnostic v0.7.1/go.mod h1:KSw6sxnxEBFM8jLPfJd46xZP+yQcfE8XkiqfZx5zR28= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= 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= @@ -136,16 +222,26 @@ github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgo github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.30 h1:bVreufq3EAIG1Quvws73du3/QgdeZ3myglJlrzSYYCY= github.com/mattn/go-sqlite3 v1.14.30/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= +github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= +github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0= +github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= @@ -158,8 +254,12 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -170,19 +270,29 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -210,8 +320,10 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= @@ -248,6 +360,8 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -255,8 +369,18 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -265,6 +389,8 @@ golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -283,6 +409,13 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -295,17 +428,32 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -313,8 +461,13 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -331,14 +484,39 @@ golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -346,8 +524,17 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= @@ -377,6 +564,11 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -428,15 +620,27 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= -gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI= +gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -444,6 +648,34 @@ honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg= +modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74= +modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/auth/auth.go b/internal/auth/auth.go deleted file mode 100644 index 20d4016..0000000 --- a/internal/auth/auth.go +++ /dev/null @@ -1,378 +0,0 @@ -package auth - -import ( - "context" - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" - "github.com/nickheyer/discopanel/internal/db" - "golang.org/x/crypto/bcrypt" -) - -var ( - ErrInvalidCredentials = errors.New("invalid credentials") - ErrUserNotActive = errors.New("user is not active") - ErrAuthDisabled = errors.New("authentication is disabled") - ErrInvalidToken = errors.New("invalid token") - ErrSessionExpired = errors.New("session expired") - AnonymousUser = &db.User{ - ID: "anonymous", - Username: "anonymous", - Role: db.RoleAdmin, - IsActive: true, - } -) - -type Manager struct { - store *db.Store -} - -func NewManager(store *db.Store) *Manager { - return &Manager{ - store: store, - } -} - -// Hashes plaintext -func HashPassword(password string) (string, error) { - bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - return string(bytes), err -} - -// Compares a hashed pass with plaintext -func CheckPassword(hashedPassword, password string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) - return err == nil -} - -// Generate a random secret key -func GenerateSecretKey() (string, error) { - bytes := make([]byte, 32) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return base64.URLEncoding.EncodeToString(bytes), nil -} - -func GetAnonUser() *db.User { - return AnonymousUser -} - -// Generate JWT token for a user -func (m *Manager) GenerateJWT(user *db.User, authConfig *db.AuthConfig) (string, error) { - claims := jwt.MapClaims{ - "user_id": user.ID, - "username": user.Username, - "role": user.Role, - "exp": time.Now().Add(time.Duration(authConfig.SessionTimeout) * time.Second).Unix(), - "iat": time.Now().Unix(), - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString([]byte(authConfig.JWTSecret)) -} - -// Validate JWT token and returns the claims -func (m *Manager) ValidateJWT(tokenString string, authConfig *db.AuthConfig) (jwt.MapClaims, error) { - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(authConfig.JWTSecret), nil - }) - - if err != nil { - return nil, err - } - - if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { - // Check expiration - if exp, ok := claims["exp"].(float64); ok { - if time.Now().Unix() > int64(exp) { - return nil, ErrSessionExpired - } - } - return claims, nil - } - - return nil, ErrInvalidToken -} - -// Authenticates a user and creates a session -func (m *Manager) Login(ctx context.Context, username, password string) (*db.User, string, error) { - // Check if auth is enabled - authConfig, _, err := m.store.GetAuthConfig(ctx) - if err != nil { - return nil, "", err - } - - if !authConfig.Enabled { - // Auth is disabled - return anon admin - return GetAnonUser(), "", nil - } - - // Get user by username - user, err := m.store.GetUserByUsername(ctx, username) - if err != nil { - return nil, "", ErrInvalidCredentials - } - - // Check password - if !CheckPassword(user.PasswordHash, password) { - return nil, "", ErrInvalidCredentials - } - - // Check if user is active - if !user.IsActive { - return nil, "", ErrUserNotActive - } - - // Generate JWT token - token, err := m.GenerateJWT(user, authConfig) - if err != nil { - return nil, "", err - } - - // Create session - session := &db.Session{ - UserID: user.ID, - Token: token, - ExpiresAt: time.Now().Add(time.Duration(authConfig.SessionTimeout) * time.Second), - } - if err := m.store.CreateSession(ctx, session); err != nil { - return nil, "", err - } - - // Update last login - now := time.Now() - user.LastLogin = &now - if err := m.store.UpdateUser(ctx, user); err != nil { - // Non-critical error, log but don't fail - } - - return user, token, nil -} - -// Logout deletes a user session -func (m *Manager) Logout(ctx context.Context, token string) error { - return m.store.DeleteSession(ctx, token) -} - -// Validate session token -func (m *Manager) ValidateSession(ctx context.Context, token string) (*db.User, error) { - // Check if auth is enabled - authConfig, _, err := m.store.GetAuthConfig(ctx) - if err != nil { - return nil, err - } - - if !authConfig.Enabled { - // Auth is disabled - return anon admin - return GetAnonUser(), nil - } - - // Auth enabled and no token means err - if token == "" { - return nil, ErrInvalidToken - } - - // Validate JWT - claims, err := m.ValidateJWT(token, authConfig) - if err != nil { - return nil, err - } - - // Get session from database - session, err := m.store.GetSession(ctx, token) - if err != nil { - return nil, ErrSessionExpired - } - - // Verify user ID matches - if userID, ok := claims["user_id"].(string); ok { - if session.UserID != userID { - return nil, ErrInvalidToken - } - } - - return session.User, nil -} - -// Create new user -func (m *Manager) CreateUser(ctx context.Context, username, email, password string, role db.UserRole) (*db.User, error) { - // Hash password - hashedPassword, err := HashPassword(password) - if err != nil { - return nil, err - } - - // Handle optional email - var emailPtr *string - if email != "" { - emailPtr = &email - } - - // Create user - user := &db.User{ - ID: uuid.New().String(), - Username: username, - Email: emailPtr, - PasswordHash: hashedPassword, - Role: role, - IsActive: true, - } - - if err := m.store.CreateUser(ctx, user); err != nil { - return nil, err - } - - return user, nil -} - -// Change user's password -func (m *Manager) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error { - // Get user - user, err := m.store.GetUser(ctx, userID) - if err != nil { - return err - } - - // Verify old password - if !CheckPassword(user.PasswordHash, oldPassword) { - return ErrInvalidCredentials - } - - // Hash new password - hashedPassword, err := HashPassword(newPassword) - if err != nil { - return err - } - - // Update password - user.PasswordHash = hashedPassword - return m.store.UpdateUser(ctx, user) -} - -// Reset user's password using recovery key -func (m *Manager) ResetPassword(ctx context.Context, username, recoveryKey, newPassword string) error { - // Get auth config - authConfig, _, err := m.store.GetAuthConfig(ctx) - if err != nil { - return err - } - - // Verify recovery key - if !CheckPassword(authConfig.RecoveryKeyHash, recoveryKey) { - return ErrInvalidCredentials - } - - // Get user - user, err := m.store.GetUserByUsername(ctx, username) - if err != nil { - return err - } - - // Hash new password - hashedPassword, err := HashPassword(newPassword) - if err != nil { - return err - } - - // Update password - user.PasswordHash = hashedPassword - return m.store.UpdateUser(ctx, user) -} - -// Initialize auth config -func (m *Manager) InitializeAuth(ctx context.Context) error { - authConfig, isNew, err := m.store.GetAuthConfig(ctx) - if err != nil { - return err - } - - if isNew || authConfig.JWTSecret == "" { - // Generate JWT secret - jwtSecret, err := GenerateSecretKey() - if err != nil { - return err - } - authConfig.JWTSecret = jwtSecret - - // Generate recovery key - recoveryKey, err := GenerateSecretKey() - if err != nil { - return err - } - authConfig.RecoveryKey = recoveryKey - - // Hash recovery key for storage - hashedRecovery, err := HashPassword(recoveryKey) - if err != nil { - return err - } - authConfig.RecoveryKeyHash = hashedRecovery - - // Save config - if err := m.store.SaveAuthConfig(ctx, authConfig); err != nil { - return err - } - - // Write recovery key to file (only on first initialization) - if err := m.saveRecoveryKey(recoveryKey); err != nil { - // Log error but don't fail - fmt.Printf("Warning: Could not save recovery key to file: %v\n", err) - } - } - - return nil -} - -// Save recovery key to a file -func (m *Manager) saveRecoveryKey(key string) error { - // Save to file - if err := SaveRecoveryKeyToFile(key); err != nil { - // If file save fails, at least print it - fmt.Printf("\n===========================================\n") - fmt.Printf("IMPORTANT: Save this recovery key securely!\n") - fmt.Printf("Recovery Key: %s\n", key) - fmt.Printf("===========================================\n\n") - return err - } - path, _ := GetRecoveryKeyPath() - fmt.Printf("\n===========================================\n") - fmt.Printf("Recovery key has been saved to: %s\n", path) - fmt.Printf("Recovery Key: %s\n", key) - fmt.Printf("IMPORTANT: Keep this key secure!\n") - fmt.Printf("===========================================\n\n") - - return nil -} - -// Check user has permission for an action -func CheckPermission(user *db.User, requiredRole db.UserRole) bool { - if user == nil { - return false - } - - // Admin can do everything - if user.Role == db.RoleAdmin { - return true - } - - // Editor can do editor and viewer actions - if user.Role == db.RoleEditor && (requiredRole == db.RoleEditor || requiredRole == db.RoleViewer) { - return true - } - - // Viewer can only do viewer actions - if user.Role == db.RoleViewer && requiredRole == db.RoleViewer { - return true - } - - return false -} diff --git a/internal/auth/context.go b/internal/auth/context.go new file mode 100644 index 0000000..9459999 --- /dev/null +++ b/internal/auth/context.go @@ -0,0 +1,30 @@ +package auth + +import "context" + +type contextKey string + +const UserContextKey contextKey = "authenticated_user" + +// AuthenticatedUser represents a validated user in context +type AuthenticatedUser struct { + ID string + Username string + Email string + Roles []string + Provider string // "local" or "oidc" +} + +// GetUserFromContext retrieves the authenticated user from context +func GetUserFromContext(ctx context.Context) *AuthenticatedUser { + user, ok := ctx.Value(UserContextKey).(*AuthenticatedUser) + if !ok { + return nil + } + return user +} + +// WithUser adds the authenticated user to context +func WithUser(ctx context.Context, user *AuthenticatedUser) context.Context { + return context.WithValue(ctx, UserContextKey, user) +} diff --git a/internal/auth/manager.go b/internal/auth/manager.go new file mode 100644 index 0000000..ff2585e --- /dev/null +++ b/internal/auth/manager.go @@ -0,0 +1,475 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/nickheyer/discopanel/internal/config" + "github.com/nickheyer/discopanel/internal/db" + "github.com/nickheyer/discopanel/internal/rbac" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrUserNotActive = errors.New("user is not active") + ErrInvalidToken = errors.New("invalid token") + ErrSessionExpired = errors.New("session expired") + ErrLocalAuthDisabled = errors.New("local authentication is disabled") + ErrRegistrationDisabled = errors.New("registration is disabled") + ErrSessionTimeoutMin = errors.New("session timeout must be at least 300 seconds (5 minutes)") + ErrAPITokenExpired = errors.New("api token has expired") + ErrAPITokenNotFound = errors.New("api token not found") +) + +// Auth override keys +const ( + settingLocalEnabled = "auth.local.enabled" + settingAllowRegistration = "auth.local.allow_registration" + settingAnonymousAccess = "auth.anonymous_access" + settingSessionTimeout = "auth.session_timeout" +) + +type Manager struct { + store *db.Store + enforcer *rbac.Enforcer + config *config.AuthConfig + jwtSecret []byte +} + +const jwtSecretSettingKey = "jwt_secret" + +func NewManager(store *db.Store, enforcer *rbac.Enforcer, cfg *config.AuthConfig) (*Manager, error) { + ctx := context.Background() + var secret []byte + + // Priority: config value → DB-stored value → generate + persist to DB + if cfg.JWTSecret != "" { + secret = []byte(cfg.JWTSecret) + } else { + stored, err := store.GetSystemSetting(ctx, jwtSecretSettingKey) + if err == nil && stored != "" { + secret, err = hex.DecodeString(stored) + if err != nil { + return nil, fmt.Errorf("failed to decode stored JWT secret: %w", err) + } + } else { + // Generate new secret and persist it + secret = make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, fmt.Errorf("failed to generate JWT secret: %w", err) + } + if err := store.SetSystemSetting(ctx, jwtSecretSettingKey, hex.EncodeToString(secret)); err != nil { + return nil, fmt.Errorf("failed to persist JWT secret: %w", err) + } + // Clean all sessions since old tokens are now invalid + _ = store.CleanAllSessions(ctx) + } + } + + m := &Manager{ + store: store, + enforcer: enforcer, + config: cfg, + jwtSecret: secret, + } + + m.loadSettingOverrides(ctx) + + return m, nil +} + +func (m *Manager) Login(ctx context.Context, username, password string) (*db.User, []string, string, time.Time, error) { + if !m.config.Local.Enabled { + return nil, nil, "", time.Time{}, ErrLocalAuthDisabled + } + + user, err := m.store.GetUserByUsernameAndProvider(ctx, username, "local") + if err != nil { + return nil, nil, "", time.Time{}, ErrInvalidCredentials + } + + if !checkPassword(user.PasswordHash, password) { + return nil, nil, "", time.Time{}, ErrInvalidCredentials + } + + if !user.IsActive { + return nil, nil, "", time.Time{}, ErrUserNotActive + } + + // Get user roles + roleNames, err := m.store.GetUserRoleNames(ctx, user.ID) + if err != nil { + return nil, nil, "", time.Time{}, fmt.Errorf("failed to get user roles: %w", err) + } + + // Generate token + expiresAt := time.Now().Add(time.Duration(m.config.SessionTimeout) * time.Second) + token, err := m.generateJWT(user.ID, user.Username, roleNames, expiresAt) + if err != nil { + return nil, nil, "", time.Time{}, err + } + + // Create session + session := &db.Session{ + ID: uuid.New().String(), + UserID: user.ID, + Token: token, + ExpiresAt: expiresAt, + } + if err := m.store.CreateSession(ctx, session); err != nil { + return nil, nil, "", time.Time{}, err + } + + // Update last login + now := time.Now() + user.LastLogin = &now + _ = m.store.UpdateUser(ctx, user) + + return user, roleNames, token, expiresAt, nil +} + +func (m *Manager) ValidateSession(ctx context.Context, token string) (*AuthenticatedUser, error) { + if token == "" { + return nil, ErrInvalidToken + } + + // Validate JWT + claims, err := m.validateJWT(token) + if err != nil { + return nil, err + } + + // Get session from database + session, err := m.store.GetSession(ctx, token) + if err != nil { + return nil, ErrSessionExpired + } + + userID, _ := claims["user_id"].(string) + if session.UserID != userID { + return nil, ErrInvalidToken + } + + // Get user + user, err := m.store.GetUser(ctx, userID) + if err != nil { + return nil, err + } + + if !user.IsActive { + return nil, ErrUserNotActive + } + + // Get roles + roleNames, err := m.store.GetUserRoleNames(ctx, user.ID) + if err != nil { + return nil, err + } + + authUser := &AuthenticatedUser{ + ID: user.ID, + Username: user.Username, + Roles: roleNames, + Provider: user.AuthProvider, + } + if user.Email != nil { + authUser.Email = *user.Email + } + + return authUser, nil +} + +func (m *Manager) Logout(ctx context.Context, token string) error { + return m.store.DeleteSession(ctx, token) +} + +func (m *Manager) CreateLocalUser(ctx context.Context, username, email, password string) (*db.User, error) { + hashedPassword, err := hashPassword(password) + if err != nil { + return nil, err + } + + var emailPtr *string + if email != "" { + emailPtr = &email + } + + user := &db.User{ + ID: uuid.New().String(), + Username: username, + Email: emailPtr, + PasswordHash: hashedPassword, + AuthProvider: "local", + IsActive: true, + } + + if err := m.store.CreateUser(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +func (m *Manager) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error { + user, err := m.store.GetUser(ctx, userID) + if err != nil { + return err + } + + if user.AuthProvider != "local" { + return errors.New("password change only available for local auth users") + } + + if !checkPassword(user.PasswordHash, oldPassword) { + return ErrInvalidCredentials + } + + hashedPassword, err := hashPassword(newPassword) + if err != nil { + return err + } + + user.PasswordHash = hashedPassword + return m.store.UpdateUser(ctx, user) +} + +func (m *Manager) AnonymousUser() *AuthenticatedUser { + return &AuthenticatedUser{ + ID: "anonymous", + Username: "anonymous", + Roles: []string{"anonymous"}, + Provider: "anonymous", + } +} + +func (m *Manager) IsAnonymousAccessEnabled() bool { + return m.config.AnonymousAccess +} + +func (m *Manager) IsAnyAuthEnabled() bool { + return m.config.Local.Enabled || m.config.OIDC.Enabled +} + +func (m *Manager) IsLocalAuthEnabled() bool { + return m.config.Local.Enabled +} + +func (m *Manager) IsRegistrationAllowed() bool { + return m.config.Local.Enabled && m.config.Local.AllowRegistration +} + +func (m *Manager) GetConfig() *config.AuthConfig { + return m.config +} + +// loadSettingOverrides reads SystemSetting overrides from the DB and applies +// them to the in-memory config, so DB values take precedence over config.yaml. +func (m *Manager) loadSettingOverrides(ctx context.Context) { + if v, err := m.store.GetSystemSetting(ctx, settingLocalEnabled); err == nil { + if b, err := strconv.ParseBool(v); err == nil { + m.config.Local.Enabled = b + } + } + if v, err := m.store.GetSystemSetting(ctx, settingAllowRegistration); err == nil { + if b, err := strconv.ParseBool(v); err == nil { + m.config.Local.AllowRegistration = b + } + } + if v, err := m.store.GetSystemSetting(ctx, settingAnonymousAccess); err == nil { + if b, err := strconv.ParseBool(v); err == nil { + m.config.AnonymousAccess = b + } + } + if v, err := m.store.GetSystemSetting(ctx, settingSessionTimeout); err == nil { + if i, err := strconv.Atoi(v); err == nil && i > 0 { + m.config.SessionTimeout = i + } + } +} + +// UpdateSettings updates mutable auth settings. Only non-nil parameters are applied. +func (m *Manager) UpdateSettings(ctx context.Context, localEnabled, allowReg, anonAccess *bool, sessionTimeout *int32) error { + // Validate session timeout + if sessionTimeout != nil && *sessionTimeout < 300 { + return ErrSessionTimeoutMin + } + + // Persist and apply each provided field + if localEnabled != nil { + if err := m.store.SetSystemSetting(ctx, settingLocalEnabled, strconv.FormatBool(*localEnabled)); err != nil { + return fmt.Errorf("failed to save local auth setting: %w", err) + } + m.config.Local.Enabled = *localEnabled + } + + if allowReg != nil { + if err := m.store.SetSystemSetting(ctx, settingAllowRegistration, strconv.FormatBool(*allowReg)); err != nil { + return fmt.Errorf("failed to save registration setting: %w", err) + } + m.config.Local.AllowRegistration = *allowReg + } + + if anonAccess != nil { + if err := m.store.SetSystemSetting(ctx, settingAnonymousAccess, strconv.FormatBool(*anonAccess)); err != nil { + return fmt.Errorf("failed to save anonymous access setting: %w", err) + } + m.config.AnonymousAccess = *anonAccess + } + + if sessionTimeout != nil { + if err := m.store.SetSystemSetting(ctx, settingSessionTimeout, strconv.Itoa(int(*sessionTimeout))); err != nil { + return fmt.Errorf("failed to save session timeout setting: %w", err) + } + m.config.SessionTimeout = int(*sessionTimeout) + } + + return nil +} + +// Creates a new API token for a user. Plaintext is returned, SHA-256 hash is stored +func (m *Manager) GenerateAPIToken(ctx context.Context, userID, name string, expiresInDays *int32) (string, *db.APIToken, error) { + // Generate 32 random bytes + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", nil, fmt.Errorf("failed to generate token: %w", err) + } + + plaintext := "dp_" + base64.RawURLEncoding.EncodeToString(raw) + + // SHA-256 hash for storage + hash := sha256.Sum256([]byte(plaintext)) + hashHex := hex.EncodeToString(hash[:]) + + var expiresAt *time.Time + if expiresInDays != nil && *expiresInDays > 0 { + t := time.Now().Add(time.Duration(*expiresInDays) * 24 * time.Hour) + expiresAt = &t + } + + token := &db.APIToken{ + ID: uuid.New().String(), + UserID: userID, + Name: name, + TokenHash: hashHex, + ExpiresAt: expiresAt, + } + + if err := m.store.CreateAPIToken(ctx, token); err != nil { + return "", nil, fmt.Errorf("failed to store api token: %w", err) + } + + return plaintext, token, nil +} + +// Validates a raw API token (dp_...) and returns the authenticated user. +func (m *Manager) ValidateAPIToken(ctx context.Context, rawToken string) (*AuthenticatedUser, error) { + if !strings.HasPrefix(rawToken, "dp_") { + return nil, ErrInvalidToken + } + + // Hash the incoming token + hash := sha256.Sum256([]byte(rawToken)) + hashHex := hex.EncodeToString(hash[:]) + + // Look up by hash + apiToken, err := m.store.GetAPITokenByHash(ctx, hashHex) + if err != nil { + return nil, ErrAPITokenNotFound + } + + // Check expiry + if apiToken.ExpiresAt != nil && apiToken.ExpiresAt.Before(time.Now()) { + return nil, ErrAPITokenExpired + } + + // Resolve user + user, err := m.store.GetUser(ctx, apiToken.UserID) + if err != nil { + return nil, fmt.Errorf("failed to get token user: %w", err) + } + + if !user.IsActive { + return nil, ErrUserNotActive + } + + // Get roles + roleNames, err := m.store.GetUserRoleNames(ctx, user.ID) + if err != nil { + return nil, fmt.Errorf("failed to get user roles: %w", err) + } + + // Background-update last_used_at + go func() { + _ = m.store.UpdateAPITokenLastUsed(context.Background(), apiToken.ID) + }() + + authUser := &AuthenticatedUser{ + ID: user.ID, + Username: user.Username, + Roles: roleNames, + Provider: user.AuthProvider, + } + if user.Email != nil { + authUser.Email = *user.Email + } + + return authUser, nil +} + +func (m *Manager) generateJWT(userID, username string, roles []string, expiresAt time.Time) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "username": username, + "roles": roles, + "exp": expiresAt.Unix(), + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(m.jwtSecret) +} + +func (m *Manager) validateJWT(tokenString string) (jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return m.jwtSecret, nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + if exp, ok := claims["exp"].(float64); ok { + if time.Now().Unix() > int64(exp) { + return nil, ErrSessionExpired + } + } + return claims, nil + } + + return nil, ErrInvalidToken +} + +func hashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func checkPassword(hashedPassword, password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) + return err == nil +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go deleted file mode 100644 index e85a334..0000000 --- a/internal/auth/middleware.go +++ /dev/null @@ -1,163 +0,0 @@ -package auth - -import ( - "context" - "net/http" - "strings" - - "github.com/nickheyer/discopanel/internal/db" -) - -type contextKey string - -const ( - UserContextKey contextKey = "user" -) - -// Middleware provides authentication middleware for HTTP handlers -type Middleware struct { - authManager *Manager - store *db.Store -} - -// NewMiddleware creates a new authentication middleware -func NewMiddleware(authManager *Manager, store *db.Store) *Middleware { - return &Middleware{ - authManager: authManager, - store: store, - } -} - -// RequireAuth middleware checks if authentication is enabled and validates the user -func (m *Middleware) RequireAuth(requiredRole db.UserRole) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check if auth is enabled - authConfig, _, err := m.store.GetAuthConfig(r.Context()) - if err != nil { - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - // If auth is disabled, allow unrestricted access - if !authConfig.Enabled { - next.ServeHTTP(w, r) - return - } - - // Extract token from Authorization header - token := extractToken(r) - if token == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - // Validate session - user, err := m.authManager.ValidateSession(r.Context(), token) - if err != nil { - http.Error(w, "Invalid or expired token", http.StatusUnauthorized) - return - } - - // Check permission - if !CheckPermission(user, requiredRole) { - http.Error(w, "Insufficient permissions", http.StatusForbidden) - return - } - - // Add user to context - ctx := context.WithValue(r.Context(), UserContextKey, user) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } -} - -// OptionalAuth middleware checks authentication if present but doesn't require it -func (m *Middleware) OptionalAuth() func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check if auth is enabled - authConfig, _, err := m.store.GetAuthConfig(r.Context()) - if err != nil { - // Continue without auth on error - next.ServeHTTP(w, r) - return - } - - // If auth is disabled, continue without user - if !authConfig.Enabled { - next.ServeHTTP(w, r) - return - } - - // Extract token from Authorization header - token := extractToken(r) - if token != "" { - // Try to validate session - user, err := m.authManager.ValidateSession(r.Context(), token) - if err == nil && user != nil { - // Add user to context if valid - ctx := context.WithValue(r.Context(), UserContextKey, user) - r = r.WithContext(ctx) - } - } - - next.ServeHTTP(w, r) - }) - } -} - -// CheckAuthStatus returns a middleware that adds auth status to response headers -func (m *Middleware) CheckAuthStatus() func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check if auth is enabled - authConfig, _, err := m.store.GetAuthConfig(r.Context()) - if err != nil { - w.Header().Set("X-Auth-Enabled", "error") - } else if authConfig.Enabled { - w.Header().Set("X-Auth-Enabled", "true") - - // Check if this is the first user setup - userCount, _ := m.store.CountUsers(r.Context()) - if userCount == 0 { - w.Header().Set("X-Auth-First-User", "true") - } - } else { - w.Header().Set("X-Auth-Enabled", "false") - } - - next.ServeHTTP(w, r) - }) - } -} - -// extractToken extracts the JWT token from the Authorization header -func extractToken(r *http.Request) string { - // Check Authorization header - authHeader := r.Header.Get("Authorization") - if authHeader != "" { - // Bearer token - parts := strings.Split(authHeader, " ") - if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { - return parts[1] - } - } - - // Check cookie as fallback - cookie, err := r.Cookie("auth_token") - if err == nil && cookie != nil { - return cookie.Value - } - - return "" -} - -// GetUserFromContext retrieves the user from the request context -func GetUserFromContext(ctx context.Context) *db.User { - user, ok := ctx.Value(UserContextKey).(*db.User) - if !ok { - return nil - } - return user -} \ No newline at end of file diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 0000000..c510f30 --- /dev/null +++ b/internal/auth/oidc.go @@ -0,0 +1,342 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/tls" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/uuid" + "github.com/nickheyer/discopanel/internal/config" + "github.com/nickheyer/discopanel/internal/db" + "github.com/nickheyer/discopanel/pkg/logger" + "golang.org/x/oauth2" +) + +type OIDCHandler struct { + manager *Manager + store *db.Store + config *config.OIDCConfig + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + oauth2Config *oauth2.Config + httpClient *http.Client + log *logger.Logger +} + +func NewOIDCHandler(manager *Manager, store *db.Store, cfg *config.OIDCConfig, log *logger.Logger) (*OIDCHandler, error) { + if !cfg.Enabled { + return &OIDCHandler{ + manager: manager, + store: store, + config: cfg, + log: log, + }, nil + } + + var httpClient *http.Client + if cfg.SkipTLSVerify { + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + log.Warn("OIDC: TLS verification disabled") + } + + ctx := context.Background() + if httpClient != nil { + ctx = oidc.ClientContext(ctx, httpClient) + } + provider, err := oidc.NewProvider(ctx, cfg.IssuerURI) + if err != nil { + return nil, fmt.Errorf("failed to create OIDC provider: %w", err) + } + + verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}) + + scopes := cfg.Scopes + if len(scopes) == 0 { + scopes = []string{oidc.ScopeOpenID, "profile", "email"} + } + + oauth2Config := &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURL, + Endpoint: provider.Endpoint(), + Scopes: scopes, + } + + return &OIDCHandler{ + manager: manager, + store: store, + config: cfg, + provider: provider, + verifier: verifier, + oauth2Config: oauth2Config, + httpClient: httpClient, + log: log, + }, nil +} + +func (h *OIDCHandler) IsEnabled() bool { + return h.config.Enabled && h.provider != nil +} + +func (h *OIDCHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { + if !h.IsEnabled() { + http.Error(w, "OIDC is not enabled", http.StatusBadRequest) + return + } + + state, err := generateState() + if err != nil { + http.Error(w, "Failed to generate state", http.StatusInternalServerError) + return + } + + // Store state in cookie + http.SetCookie(w, &http.Cookie{ + Name: "oidc_state", + Value: state, + Path: "/", + MaxAge: 300, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + http.Redirect(w, r, h.oauth2Config.AuthCodeURL(state), http.StatusFound) +} + +func (h *OIDCHandler) HandleCallback(w http.ResponseWriter, r *http.Request) { + if !h.IsEnabled() { + http.Error(w, "OIDC is not enabled", http.StatusBadRequest) + return + } + + // Verify state + stateCookie, err := r.Cookie("oidc_state") + if err != nil || stateCookie.Value != r.URL.Query().Get("state") { + http.Error(w, "Invalid state parameter", http.StatusBadRequest) + return + } + + // Clear state cookie + http.SetCookie(w, &http.Cookie{ + Name: "oidc_state", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) + + // Exchange code for token + ctx := r.Context() + if h.httpClient != nil { + ctx = oidc.ClientContext(ctx, h.httpClient) + } + oauth2Token, err := h.oauth2Config.Exchange(ctx, r.URL.Query().Get("code")) + if err != nil { + h.log.Error("OIDC: failed to exchange code for token: %v", err) + http.Error(w, "Failed to exchange code for token", http.StatusInternalServerError) + return + } + + // Extract ID token + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + h.log.Error("OIDC: no id_token in token response") + http.Error(w, "No id_token in response", http.StatusInternalServerError) + return + } + + // Verify ID token + idToken, err := h.verifier.Verify(ctx, rawIDToken) + if err != nil { + h.log.Error("OIDC: failed to verify ID token: %v", err) + http.Error(w, "Failed to verify ID token", http.StatusInternalServerError) + return + } + + // Extract claims from ID token + var claims map[string]any + if err := idToken.Claims(&claims); err != nil { + h.log.Error("OIDC: failed to parse claims: %v", err) + http.Error(w, "Failed to parse claims", http.StatusInternalServerError) + return + } + + // Fetch UserInfo - some oidc sets role/groups here + tokenSource := h.oauth2Config.TokenSource(ctx, oauth2Token) + userInfo, err := h.provider.UserInfo(ctx, tokenSource) + if err == nil { + var uiClaims map[string]any + if err := userInfo.Claims(&uiClaims); err == nil { + for k, v := range uiClaims { + if _, exists := claims[k]; !exists { + claims[k] = v + } + } + } + } + + // Extract user info from claims + sub := idToken.Subject + email, _ := claims["email"].(string) + username, _ := claims["preferred_username"].(string) + if username == "" { + username, _ = claims["name"].(string) + } + if username == "" { + username = email + } + if username == "" { + username = sub + } + + user, err := h.findOrCreateOIDCUser(ctx, sub, username, email) + if err != nil { + h.log.Error("OIDC: failed to find or create user (sub=%s, username=%s): %v", sub, username, err) + http.Error(w, "Failed to authenticate user", http.StatusInternalServerError) + return + } + + // Map OIDC claims to roles + h.mapClaimsToRoles(ctx, user.ID, claims) + + // Get user roles + roleNames, err := h.store.GetUserRoleNames(ctx, user.ID) + if err != nil { + roleNames = []string{} + } + + // Generate session token + expiresAt := time.Now().Add(time.Duration(h.manager.config.SessionTimeout) * time.Second) + token, err := h.manager.generateJWT(user.ID, user.Username, roleNames, expiresAt) + if err != nil { + h.log.Error("OIDC: failed to generate JWT: %v", err) + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + + // Create session + session := &db.Session{ + ID: uuid.New().String(), + UserID: user.ID, + Token: token, + ExpiresAt: expiresAt, + } + if err := h.store.CreateSession(ctx, session); err != nil { + h.log.Error("OIDC: failed to create session: %v", err) + http.Error(w, "Failed to create session", http.StatusInternalServerError) + return + } + + h.log.Info("OIDC: user %s authenticated successfully", user.Username) + + // Redirect to frontend with token in query param + http.Redirect(w, r, fmt.Sprintf("/login?token=%s", token), http.StatusFound) +} + +// findOrCreateOIDCUser looks up a user by OIDC subject (returning user), +// or creates a new OIDC user. Local users with the same username are not +// affected — the composite unique constraint (username, auth_provider) +// allows both to coexist. +func (h *OIDCHandler) findOrCreateOIDCUser(ctx context.Context, sub, username, email string) (*db.User, error) { + // Step 1: try to find by OIDC subject (returning user) + if user, err := h.store.GetUserByOIDCSubject(ctx, sub); err == nil { + if !user.IsActive { + return nil, ErrUserNotActive + } + // Update email/last login on returning users + if email != "" { + user.Email = &email + } + now := time.Now() + user.LastLogin = &now + _ = h.store.UpdateUser(ctx, user) + return user, nil + } + + // Step 2: create a new OIDC user + var emailPtr *string + if email != "" { + emailPtr = &email + } + user := &db.User{ + ID: uuid.New().String(), + Username: username, + Email: emailPtr, + AuthProvider: "oidc", + OIDCSubject: sub, + OIDCIssuer: h.config.IssuerURI, + IsActive: true, + } + if err := h.store.CreateUser(ctx, user); err != nil { + return nil, fmt.Errorf("failed to create OIDC user: %w", err) + } + + // Assign default roles to new user + defaultRoles, _ := h.store.GetDefaultRoles(ctx) + for _, role := range defaultRoles { + _ = h.store.AssignRole(ctx, user.ID, role.Name, "oidc") + } + + h.log.Info("OIDC: created new user %s", user.Username) + return user, nil +} + +func (h *OIDCHandler) mapClaimsToRoles(ctx context.Context, userID string, claims map[string]any) { + if h.config.RoleClaim == "" { + return + } + + // Extract groups/roles from claims + var claimValues []string + claimValue, ok := claims[h.config.RoleClaim] + if !ok { + h.log.Warn("OIDC: role claim %q not found in token claims", h.config.RoleClaim) + return + } + switch v := claimValue.(type) { + case []any: + for _, item := range v { + if s, ok := item.(string); ok { + claimValues = append(claimValues, s) + } + } + case string: + // Try JSON array + var arr []string + if err := json.Unmarshal([]byte(v), &arr); err == nil { + claimValues = arr + } else { + claimValues = []string{v} + } + } + + // Map OIDC claims to local roles + use role mappings if provided in cfg + for _, claimVal := range claimValues { + if len(h.config.RoleMapping) > 0 { + if localRole, ok := h.config.RoleMapping[claimVal]; ok { + _ = h.store.AssignRole(ctx, userID, localRole, "oidc") + } + } else { + _ = h.store.AssignRole(ctx, userID, claimVal, "oidc") + } + } +} + +func generateState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} diff --git a/internal/auth/recovery.go b/internal/auth/recovery.go deleted file mode 100644 index a023eb5..0000000 --- a/internal/auth/recovery.go +++ /dev/null @@ -1,84 +0,0 @@ -package auth - -import ( - "fmt" - "os" - "path/filepath" -) - -// GetRecoveryKeyPath returns the path where the recovery key should be stored -func GetRecoveryKeyPath() (string, error) { - // Get the data directory from environment or use default - dataDir := os.Getenv("DISCOPANEL_DATA_DIR") - if dataDir == "" { - // Use current working directory - cwd, err := os.Getwd() - if err != nil { - return "", err - } - dataDir = filepath.Join(cwd, "data") - } - - // Ensure directory exists - if err := os.MkdirAll(dataDir, 0700); err != nil { - return "", err - } - - return filepath.Join(dataDir, ".recovery_key"), nil -} - -// SaveRecoveryKeyToFile saves the recovery key to a secure file -func SaveRecoveryKeyToFile(key string) error { - path, err := GetRecoveryKeyPath() - if err != nil { - return err - } - - // Write key to file with restricted permissions (owner read only) - content := "DiscoPanel Recovery Key\n========================\n\n" - content += fmt.Sprintf("Key: %s\n\n", key) - content += "IMPORTANT: Keep this key secure! It can be used to reset any user password.\n" - content += "Store it in a safe place outside of this server.\n\n" - content += "To use this key for password recovery:\n" - content += "1. Access the login page\n" - content += "2. Click 'Forgot Password'\n" - content += "3. Enter your username and this recovery key\n" - content += "4. Set a new password\n" - - return os.WriteFile(path, []byte(content), 0400) -} - -// ReadRecoveryKeyFromFile reads the recovery key from file (for display purposes only) -func ReadRecoveryKeyFromFile() (string, error) { - path, err := GetRecoveryKeyPath() - if err != nil { - return "", err - } - - data, err := os.ReadFile(path) - if err != nil { - return "", err - } - - // Parse the key from the file content - // This is a simple implementation - in production, you might want more robust parsing - lines := string(data) - keyPrefix := "Key: " - start := 0 - for i := 0; i < len(lines); i++ { - if i+len(keyPrefix) <= len(lines) && lines[i:i+len(keyPrefix)] == keyPrefix { - start = i + len(keyPrefix) - break - } - } - - if start > 0 { - end := start - for end < len(lines) && lines[end] != '\n' { - end++ - } - return lines[start:end], nil - } - - return "", fmt.Errorf("recovery key not found in file") -} diff --git a/internal/config/config.go b/internal/config/config.go index a34dc4f..4e57610 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,7 +6,6 @@ import ( "slices" "strings" - "github.com/nickheyer/discopanel/internal/db" "github.com/spf13/viper" ) @@ -20,6 +19,32 @@ type Config struct { Minecraft MinecraftConfig `mapstructure:"minecraft" json:"minecraft"` Logging LoggingConfig `mapstructure:"logging" json:"logging"` Upload UploadConfig `mapstructure:"upload" json:"upload"` + Auth AuthConfig `mapstructure:"auth" json:"auth"` +} + +type AuthConfig struct { + SessionTimeout int `mapstructure:"session_timeout" json:"session_timeout"` + AnonymousAccess bool `mapstructure:"anonymous_access" json:"anonymous_access"` + JWTSecret string `mapstructure:"jwt_secret" json:"jwt_secret"` + OIDC OIDCConfig `mapstructure:"oidc" json:"oidc"` + Local LocalConfig `mapstructure:"local" json:"local"` +} + +type OIDCConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + IssuerURI string `mapstructure:"issuer_uri" json:"issuer_uri"` + ClientID string `mapstructure:"client_id" json:"client_id"` + ClientSecret string `mapstructure:"client_secret" json:"client_secret"` + RedirectURL string `mapstructure:"redirect_url" json:"redirect_url"` + Scopes []string `mapstructure:"scopes" json:"scopes"` + RoleClaim string `mapstructure:"role_claim" json:"role_claim"` + RoleMapping map[string]string `mapstructure:"role_mapping" json:"role_mapping"` + SkipTLSVerify bool `mapstructure:"skip_tls_verify" json:"skip_tls_verify"` +} + +type LocalConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + AllowRegistration bool `mapstructure:"allow_registration" json:"allow_registration"` } type ServerConfig struct { @@ -31,13 +56,6 @@ type ServerConfig struct { UserAgent string `mapstructure:"user_agent" json:"user_agent"` } -type DatabaseConfig struct { - Path string `mapstructure:"path" json:"path"` - MaxConnections int `mapstructure:"max_connections" json:"max_connections"` - MaxIdleConns int `mapstructure:"max_idle_conns" json:"max_idle_conns"` - ConnMaxLifetime int `mapstructure:"conn_max_lifetime" json:"conn_max_lifetime"` -} - type DockerConfig struct { SyncInterval int `mapstructure:"sync_interval" json:"sync_interval"` Host string `mapstructure:"host" json:"host"` @@ -68,9 +86,17 @@ type ModuleConfig struct { PortRangeMax int `mapstructure:"port_range_max" json:"port_range_max"` } +type DatabaseConfig struct { + Path string `mapstructure:"path" json:"path"` + MaxConnections int `mapstructure:"max_connections" json:"max_connections"` + MaxIdleConns int `mapstructure:"max_idle_conns" json:"max_idle_conns"` + ConnMaxLifetime int `mapstructure:"conn_max_lifetime" json:"conn_max_lifetime"` + AutoMigrate bool `mapstructure:"auto_migrate" json:"auto_migrate"` +} + type MinecraftConfig struct { - ResetGlobal bool `mapstructure:"reset_global" json:"reset_global"` - GlobalConfig db.ServerConfig `mapstructure:"global_config" json:"global_config"` + ResetGlobal bool `mapstructure:"reset_global" json:"reset_global"` + GlobalConfig map[string]any `mapstructure:"global_config" json:"global_config"` } type LoggingConfig struct { @@ -148,6 +174,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("database.max_connections", 25) v.SetDefault("database.max_idle_conns", 5) v.SetDefault("database.conn_max_lifetime", 300) + v.SetDefault("database.auto_migrate", true) // Docker defaults v.SetDefault("docker.sync_interval", 5) @@ -189,6 +216,21 @@ func setDefaults(v *viper.Viper) { v.SetDefault("logging.max_age", 30) // 30 days v.SetDefault("logging.compress", true) // compress rotated + // Auth defaults + v.SetDefault("auth.session_timeout", 86400) + v.SetDefault("auth.anonymous_access", false) + v.SetDefault("auth.jwt_secret", "") + v.SetDefault("auth.oidc.enabled", false) + v.SetDefault("auth.oidc.issuer_uri", "") + v.SetDefault("auth.oidc.client_id", "") + v.SetDefault("auth.oidc.client_secret", "") + v.SetDefault("auth.oidc.redirect_url", "") + v.SetDefault("auth.oidc.scopes", []string{"openid", "profile", "email"}) + v.SetDefault("auth.oidc.role_claim", "groups") + v.SetDefault("auth.oidc.skip_tls_verify", false) + v.SetDefault("auth.local.enabled", true) + v.SetDefault("auth.local.allow_registration", false) + // Upload defaults v.SetDefault("upload.session_ttl", 240) // 4 hours (in minutes) v.SetDefault("upload.default_chunk_size", 5*1024*1024) // 5MB @@ -243,8 +285,3 @@ func validateConfig(cfg *Config) error { return nil } - -// LoadGlobalServerConfig returns the global ServerConfig defaults from the config file -func LoadGlobalServerConfig(cfg *Config) db.ServerConfig { - return cfg.Minecraft.GlobalConfig -} diff --git a/internal/db/migrations.go b/internal/db/migrations.go new file mode 100644 index 0000000..ef393d1 --- /dev/null +++ b/internal/db/migrations.go @@ -0,0 +1,142 @@ +package db + +import ( + "fmt" + "log" + "os" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +func allModels() []any { + return []any{ + &Server{}, + &ServerConfig{}, + &Mod{}, + &IndexedModpack{}, + &IndexedModpackFile{}, + &ModpackFavorite{}, + &ProxyConfig{}, + &ProxyListener{}, + &User{}, + &Role{}, + &UserRole{}, + &Session{}, + &APIToken{}, + &RegistrationInvite{}, + &ScheduledTask{}, + &TaskExecution{}, + &ModuleTemplate{}, + &Module{}, + &SystemSetting{}, + } +} + +func (s *Store) Migrate() error { + if err := s.backupDB(); err != nil { + return fmt.Errorf("pre-migration backup failed: %w", err) + } + + m := gormigrate.New(s.db, &gormigrate.Options{ + TableName: "migrations", + IDColumnName: "id", + IDColumnSize: 200, + UseTransaction: true, + ValidateUnknownMigrations: false, + }, migrations()) + + m.InitSchema(func(db *gorm.DB) error { + return db.AutoMigrate(allModels()...) + }) + + if err := m.Migrate(); err != nil { + return fmt.Errorf("migration failed: %w", err) + } + + if err := seeds(s); err != nil { + return fmt.Errorf("seed failed: %w", err) + } + + log.Println("[migrate] Migration complete") + return nil +} + +func seeds(s *Store) error { + for _, seed := range []func() error{ + s.SeedSystemRoles, + s.SeedGlobalSettings, + } { + if err := seed(); err != nil { + return err + } + } + return nil +} + +func migrations() []*gormigrate.Migration { + return []*gormigrate.Migration{ + { + ID: "20260226_001_backfill_user_roles", + Migrate: func(tx *gorm.DB) error { + // Find users that have no entry in user_roles + var usersWithoutRoles []User + if err := tx.Where("id NOT IN (SELECT DISTINCT user_id FROM user_roles)"). + Order("created_at ASC"). + Find(&usersWithoutRoles).Error; err != nil { + return err + } + + if len(usersWithoutRoles) == 0 { + return nil + } + + var adminCount int64 + tx.Model(&UserRole{}).Where("role_name = ?", "admin").Count(&adminCount) + + for i, user := range usersWithoutRoles { + roleName := "user" + if i == 0 && adminCount == 0 { + roleName = "admin" + } + ur := UserRole{ + ID: user.ID + "-" + roleName, + UserID: user.ID, + RoleName: roleName, + Source: "migration", + } + if err := tx.Create(&ur).Error; err != nil { + return fmt.Errorf("failed to assign role %s to user %s: %w", roleName, user.Username, err) + } + log.Printf("[migrate] Assigned role '%s' to user '%s'", roleName, user.Username) + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + return tx.Where("source = ?", "migration").Delete(&UserRole{}).Error + }, + }, + } +} + +func (s *Store) backupDB() error { + if s.cfg.Database.Path == "" || s.cfg.Database.Path == ":memory:" { + return nil + } + + var count int + row := s.db.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Row() + if err := row.Scan(&count); err != nil || count == 0 { + return nil + } + + backupPath := s.cfg.Database.Path + ".pre-migrate.bak" + os.Remove(backupPath) + if err := s.db.Exec("VACUUM INTO ?", backupPath).Error; err != nil { + return fmt.Errorf("VACUUM INTO %s: %w", backupPath, err) + } + + log.Printf("[migrate] Database backed up to %s", backupPath) + return nil +} diff --git a/internal/db/models.go b/internal/db/models.go index f4191ae..4862fab 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -386,40 +386,71 @@ type ProxyListener struct { UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } -// UserRole defines the role of a user in the system -type UserRole string - -const ( - RoleAdmin UserRole = "admin" // Full access to all features - RoleEditor UserRole = "editor" // Can manage servers but not system settings - RoleViewer UserRole = "viewer" // Read-only access -) +// RegistrationInvite represents a shareable invite link for controlled registration +type RegistrationInvite struct { + ID string `json:"id" gorm:"primaryKey"` + Code string `json:"code" gorm:"not null;uniqueIndex"` + Description string `json:"description"` + Roles []string `json:"roles" gorm:"column:roles;serializer:json"` + PinHash string `json:"-" gorm:"column:pin_hash"` + MaxUses int `json:"max_uses" gorm:"default:0;column:max_uses"` + UseCount int `json:"use_count" gorm:"default:0;column:use_count"` + ExpiresAt *time.Time `json:"expires_at" gorm:"column:expires_at"` + CreatedBy string `json:"created_by" gorm:"not null;column:created_by"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` +} // User represents a user account type User struct { ID string `json:"id" gorm:"primaryKey"` - Username string `json:"username" gorm:"not null;uniqueIndex"` - Email *string `json:"email" gorm:"uniqueIndex"` // Pointer allows NULL, unique only on non-NULL - PasswordHash string `json:"-" gorm:"not null;column:password_hash"` - Role UserRole `json:"role" gorm:"not null;default:'viewer'"` + Username string `json:"username" gorm:"not null;uniqueIndex:idx_user_provider"` + Email *string `json:"email" gorm:"index"` + PasswordHash string `json:"-" gorm:"column:password_hash"` + AuthProvider string `json:"auth_provider" gorm:"not null;default:'local';uniqueIndex:idx_user_provider"` + OIDCSubject string `json:"oidc_subject" gorm:"column:oidc_subject;uniqueIndex:idx_oidc_identity,where:oidc_subject != ''"` + OIDCIssuer string `json:"oidc_issuer" gorm:"column:oidc_issuer;uniqueIndex:idx_oidc_identity,where:oidc_subject != ''"` IsActive bool `json:"is_active" gorm:"not null;default:true"` LastLogin *time.Time `json:"last_login" gorm:"column:last_login"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } -// AuthConfig stores authentication configuration -type AuthConfig struct { - ID string `json:"id" gorm:"primaryKey"` - Enabled bool `json:"enabled" gorm:"not null;default:false"` - RecoveryKey string `json:"-" gorm:"column:recovery_key"` // Secret key for account recovery - RecoveryKeyHash string `json:"-" gorm:"column:recovery_key_hash"` // Hashed version for verification - JWTSecret string `json:"-" gorm:"column:jwt_secret"` // Secret for JWT signing - SessionTimeout int `json:"session_timeout" gorm:"default:86400"` // Session timeout in seconds (default 24h) - RequireEmailVerify bool `json:"require_email_verify" gorm:"default:false"` - AllowRegistration bool `json:"allow_registration" gorm:"default:false"` // Allow new user registration - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +// Role represents a role in the RBAC system +type Role struct { + ID string `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"not null;uniqueIndex"` + Description string `json:"description"` + IsSystem bool `json:"is_system" gorm:"not null;default:false"` + IsDefault bool `json:"is_default" gorm:"not null;default:false"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// UserRole links users to roles +type UserRole struct { + ID string `json:"id" gorm:"primaryKey"` + UserID string `json:"user_id" gorm:"not null;index;column:user_id"` + RoleName string `json:"role_name" gorm:"not null;index;column:role_name"` + Source string `json:"source" gorm:"not null;default:'local'"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` +} + +// SystemSetting stores key-value pairs for internal system configuration. +type SystemSetting struct { + Key string `gorm:"primaryKey"` + Value string `gorm:"not null"` +} + +// APIToken represents a long-lived API token for programmatic access +type APIToken struct { + ID string `json:"id" gorm:"primaryKey"` + UserID string `json:"user_id" gorm:"not null;index;column:user_id"` + Name string `json:"name" gorm:"not null"` + TokenHash string `json:"-" gorm:"not null;uniqueIndex;column:token_hash"` + ExpiresAt *time.Time `json:"expires_at" gorm:"column:expires_at"` + LastUsedAt *time.Time `json:"last_used_at" gorm:"column:last_used_at"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + User *User `json:"-" gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` } // Session represents an active user session @@ -428,8 +459,6 @@ type Session struct { UserID string `json:"user_id" gorm:"not null;index;column:user_id"` Token string `json:"-" gorm:"not null;uniqueIndex"` ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"` - IPAddress string `json:"ip_address"` - UserAgent string `json:"user_agent"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` User *User `json:"user,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` } diff --git a/internal/db/store.go b/internal/db/store.go index 091d375..626214b 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -6,24 +6,21 @@ import ( "reflect" "time" + "github.com/go-viper/mapstructure/v2" "github.com/google/uuid" + "github.com/nickheyer/discopanel/internal/config" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) -type DBConfig struct { - MaxOpenConns int - MaxIdleConns int - ConnMaxLifetime time.Duration -} - type Store struct { - db *gorm.DB + db *gorm.DB + cfg *config.Config } -func NewSQLiteStore(dbPath string, config ...DBConfig) (*Store, error) { - db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ +func NewSQLiteStore(cfg *config.Config) (*Store, error) { + db, err := gorm.Open(sqlite.Open(cfg.Database.Path), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), NowFunc: func() time.Time { return time.Now().UTC() @@ -33,35 +30,36 @@ func NewSQLiteStore(dbPath string, config ...DBConfig) (*Store, error) { return nil, fmt.Errorf("failed to open database: %w", err) } - // Get underlying SQL database to configure connection pool sqlDB, err := db.DB() if err != nil { return nil, fmt.Errorf("failed to get database handle: %w", err) } - // Apply connection pool configuration if provided - if len(config) > 0 { - cfg := config[0] - if cfg.MaxOpenConns > 0 { - sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) - } - if cfg.MaxIdleConns > 0 { - sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) - } - if cfg.ConnMaxLifetime > 0 { - sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime) - } + if cfg.Database.MaxConnections > 0 { + sqlDB.SetMaxOpenConns(cfg.Database.MaxConnections) + } + if cfg.Database.MaxIdleConns > 0 { + sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns) + } + if cfg.Database.ConnMaxLifetime > 0 { + sqlDB.SetConnMaxLifetime(time.Duration(cfg.Database.ConnMaxLifetime) * time.Second) } - store := &Store{db: db} + store := &Store{db: db, cfg: cfg} - if err := store.Migrate(); err != nil { - return nil, fmt.Errorf("failed to migrate database: %w", err) + if cfg.Database.AutoMigrate { + if err := store.Migrate(); err != nil { + return nil, fmt.Errorf("failed to migrate database: %w", err) + } } return store, nil } +func (s *Store) DB() *gorm.DB { + return s.db +} + func (s *Store) Close() error { sqlDB, err := s.db.DB() if err != nil { @@ -70,40 +68,6 @@ func (s *Store) Close() error { return sqlDB.Close() } -func (s *Store) Migrate() error { - // Auto-migrate all models - err := s.db.AutoMigrate( - &Server{}, - &ServerConfig{}, - &Mod{}, - &IndexedModpack{}, - &IndexedModpackFile{}, - &ModpackFavorite{}, - &ProxyConfig{}, - &ProxyListener{}, - &User{}, - &AuthConfig{}, - &Session{}, - &ScheduledTask{}, - &TaskExecution{}, - &ModuleTemplate{}, - &Module{}, - ) - if err != nil { - return fmt.Errorf("failed to auto-migrate: %w", err) - } - - // Create indexes - if err := s.db.Exec("CREATE INDEX IF NOT EXISTS idx_servers_port ON servers(port)").Error; err != nil { - return err - } - if err := s.db.Exec("CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)").Error; err != nil { - return err - } - - return nil -} - // Server operations func (s *Store) CreateServer(ctx context.Context, server *Server) error { err := s.db.WithContext(ctx).Create(server).Error @@ -236,7 +200,6 @@ func (s *Store) SyncServerConfigWithServer(ctx context.Context, server *Server) } } - // Helper functions stringPtr := func(s string) *string { return &s } intPtr := func(i int) *int { return &i } config.Type = stringPtr(string(server.ModLoader)) @@ -248,7 +211,6 @@ func (s *Store) SyncServerConfigWithServer(ctx context.Context, server *Server) } func (s *Store) CreateDefaultServerConfig(serverID string) *ServerConfig { - // Helper functions to create pointers boolPtr := func(b bool) *bool { return &b } stringPtr := func(s string) *string { return &s } intPtr := func(i int) *int { return &i } @@ -580,6 +542,24 @@ func (s *Store) UpdateGlobalSettings(ctx context.Context, config *ServerConfig) return s.db.WithContext(ctx).Save(config).Error } +func (s *Store) SeedGlobalSettings() error { + ctx := context.Background() + _, isNew, err := s.GetGlobalSettings(ctx) + if err != nil { + return err + } + if isNew || s.cfg.Minecraft.ResetGlobal { + gc := s.CreateDefaultServerConfig(GlobalSettingsID) + if len(s.cfg.Minecraft.GlobalConfig) > 0 { + mapstructure.WeakDecode(s.cfg.Minecraft.GlobalConfig, gc) + gc.ID = GlobalSettingsID + "-config" + gc.ServerID = GlobalSettingsID + } + return s.UpdateGlobalSettings(ctx, gc) + } + return nil +} + // ProxyConfig operations func (s *Store) GetProxyConfig(ctx context.Context) (*ProxyConfig, bool, error) { var config ProxyConfig @@ -727,9 +707,9 @@ func (s *Store) GetUser(ctx context.Context, id string) (*User, error) { return &user, nil } -func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) { +func (s *Store) GetUserByUsernameAndProvider(ctx context.Context, username, provider string) (*User, error) { var user User - err := s.db.WithContext(ctx).First(&user, "username = ?", username).Error + err := s.db.WithContext(ctx).First(&user, "username = ? AND auth_provider = ?", username, provider).Error if err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("user not found") @@ -739,9 +719,9 @@ func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, return &user, nil } -func (s *Store) GetUserByEmail(ctx context.Context, email string) (*User, error) { +func (s *Store) GetUserByOIDCSubject(ctx context.Context, subject string) (*User, error) { var user User - err := s.db.WithContext(ctx).First(&user, "email = ?", email).Error + err := s.db.WithContext(ctx).First(&user, "oidc_subject = ?", subject).Error if err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("user not found") @@ -763,11 +743,15 @@ func (s *Store) UpdateUser(ctx context.Context, user *User) error { func (s *Store) DeleteUser(ctx context.Context, id string) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // Delete sessions if err := tx.Where("user_id = ?", id).Delete(&Session{}).Error; err != nil { return err } - // Delete user + if err := tx.Where("user_id = ?", id).Delete(&APIToken{}).Error; err != nil { + return err + } + if err := tx.Where("user_id = ?", id).Delete(&UserRole{}).Error; err != nil { + return err + } return tx.Delete(&User{}, "id = ?", id).Error }) } @@ -778,31 +762,108 @@ func (s *Store) CountUsers(ctx context.Context) (int64, error) { return count, err } -// AuthConfig operations -func (s *Store) GetAuthConfig(ctx context.Context) (*AuthConfig, bool, error) { - var config AuthConfig - err := s.db.WithContext(ctx).First(&config).Error +// Role operations +func (s *Store) SeedSystemRoles() error { + roles := []Role{ + {ID: "role-admin", Name: "admin", Description: "Full system access", IsSystem: true}, + {ID: "role-user", Name: "user", Description: "Standard user access", IsSystem: true, IsDefault: true}, + {ID: "role-anonymous", Name: "anonymous", Description: "Unauthenticated user access", IsSystem: true}, + } + for _, role := range roles { + var existing Role + if err := s.db.Where("name = ?", role.Name).First(&existing).Error; err == gorm.ErrRecordNotFound { + if err := s.db.Create(&role).Error; err != nil { + return err + } + } + } + return nil +} + +func (s *Store) CreateRole(ctx context.Context, role *Role) error { + if role.ID == "" { + role.ID = uuid.New().String() + } + return s.db.WithContext(ctx).Create(role).Error +} + +func (s *Store) GetRole(ctx context.Context, id string) (*Role, error) { + var role Role + err := s.db.WithContext(ctx).First(&role, "id = ?", id).Error if err != nil { if err == gorm.ErrRecordNotFound { - // Return default config if none exists - return &AuthConfig{ - ID: "default", - Enabled: false, - SessionTimeout: 86400, // 24 hours - RequireEmailVerify: false, - AllowRegistration: false, - }, true, nil + return nil, fmt.Errorf("role not found") } - return nil, false, err + return nil, err } - return &config, false, nil + return &role, nil } -func (s *Store) SaveAuthConfig(ctx context.Context, config *AuthConfig) error { - if config.ID == "" { - config.ID = "default" +func (s *Store) ListRoles(ctx context.Context) ([]*Role, error) { + var roles []*Role + err := s.db.WithContext(ctx).Order("is_system DESC, name ASC").Find(&roles).Error + return roles, err +} + +func (s *Store) GetDefaultRoles(ctx context.Context) ([]*Role, error) { + var roles []*Role + err := s.db.WithContext(ctx).Where("is_default = ?", true).Find(&roles).Error + return roles, err +} + +func (s *Store) UpdateRole(ctx context.Context, role *Role) error { + return s.db.WithContext(ctx).Save(role).Error +} + +func (s *Store) DeleteRole(ctx context.Context, id string) error { + var role Role + if err := s.db.WithContext(ctx).First(&role, "id = ?", id).Error; err != nil { + return err } - return s.db.WithContext(ctx).Save(config).Error + if role.IsSystem { + return fmt.Errorf("cannot delete system role") + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("role_name = ?", role.Name).Delete(&UserRole{}).Error; err != nil { + return err + } + return tx.Delete(&Role{}, "id = ?", id).Error + }) +} + +// UserRole operations +func (s *Store) AssignRole(ctx context.Context, userID, roleName, source string) error { + var existing UserRole + err := s.db.WithContext(ctx).Where("user_id = ? AND role_name = ?", userID, roleName).First(&existing).Error + if err == nil { + return nil // Already assigned + } + ur := &UserRole{ + ID: uuid.New().String(), + UserID: userID, + RoleName: roleName, + Source: source, + } + return s.db.WithContext(ctx).Create(ur).Error +} + +func (s *Store) UnassignRole(ctx context.Context, userID, roleName string) error { + return s.db.WithContext(ctx).Where("user_id = ? AND role_name = ?", userID, roleName).Delete(&UserRole{}).Error +} + +func (s *Store) GetUserRoleNames(ctx context.Context, userID string) ([]string, error) { + var names []string + err := s.db.WithContext(ctx). + Model(&UserRole{}). + Select("user_roles.role_name"). + Joins("LEFT JOIN roles ON roles.name = user_roles.role_name"). + Where("user_roles.user_id = ?", userID). + Order("roles.is_system DESC, roles.name ASC"). + Pluck("user_roles.role_name", &names).Error + if err != nil { + return nil, err + } + return names, nil } // Session operations @@ -829,14 +890,119 @@ func (s *Store) DeleteSession(ctx context.Context, token string) error { return s.db.WithContext(ctx).Where("token = ?", token).Delete(&Session{}).Error } -func (s *Store) DeleteUserSessions(ctx context.Context, userID string) error { - return s.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&Session{}).Error -} - func (s *Store) CleanExpiredSessions(ctx context.Context) error { return s.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&Session{}).Error } +// CleanAllSessions deletes all sessions (used when JWT secret changes). +func (s *Store) CleanAllSessions(ctx context.Context) error { + return s.db.WithContext(ctx).Where("1 = 1").Delete(&Session{}).Error +} + +// APIToken operations +func (s *Store) CreateAPIToken(ctx context.Context, token *APIToken) error { + if token.ID == "" { + token.ID = uuid.New().String() + } + return s.db.WithContext(ctx).Create(token).Error +} + +func (s *Store) GetAPITokenByHash(ctx context.Context, hash string) (*APIToken, error) { + var token APIToken + err := s.db.WithContext(ctx).Where("token_hash = ?", hash).First(&token).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("api token not found") + } + return nil, err + } + return &token, nil +} + +func (s *Store) ListAPITokensByUser(ctx context.Context, userID string) ([]APIToken, error) { + var tokens []APIToken + err := s.db.WithContext(ctx).Where("user_id = ?", userID).Order("created_at DESC").Find(&tokens).Error + return tokens, err +} + +func (s *Store) DeleteAPIToken(ctx context.Context, id, userID string) error { + result := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).Delete(&APIToken{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("api token not found") + } + return nil +} + +func (s *Store) UpdateAPITokenLastUsed(ctx context.Context, id string) error { + return s.db.WithContext(ctx).Model(&APIToken{}).Where("id = ?", id).Update("last_used_at", time.Now()).Error +} + +// RegistrationInvite operations +func (s *Store) CreateRegistrationInvite(ctx context.Context, invite *RegistrationInvite) error { + if invite.ID == "" { + invite.ID = uuid.New().String() + } + return s.db.WithContext(ctx).Create(invite).Error +} + +func (s *Store) GetRegistrationInvite(ctx context.Context, id string) (*RegistrationInvite, error) { + var invite RegistrationInvite + err := s.db.WithContext(ctx).First(&invite, "id = ?", id).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("invite not found") + } + return nil, err + } + return &invite, nil +} + +func (s *Store) GetRegistrationInviteByCode(ctx context.Context, code string) (*RegistrationInvite, error) { + var invite RegistrationInvite + err := s.db.WithContext(ctx).First(&invite, "code = ?", code).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("invite not found") + } + return nil, err + } + return &invite, nil +} + +func (s *Store) ListRegistrationInvites(ctx context.Context) ([]*RegistrationInvite, error) { + var invites []*RegistrationInvite + err := s.db.WithContext(ctx).Order("created_at DESC").Find(&invites).Error + return invites, err +} + +func (s *Store) IncrementInviteUseCount(ctx context.Context, id string) error { + return s.db.WithContext(ctx).Model(&RegistrationInvite{}).Where("id = ?", id). + Update("use_count", gorm.Expr("use_count + 1")).Error +} + +func (s *Store) DeleteRegistrationInvite(ctx context.Context, id string) error { + return s.db.WithContext(ctx).Delete(&RegistrationInvite{}, "id = ?", id).Error +} + +// SystemSetting operations + +func (s *Store) GetSystemSetting(ctx context.Context, key string) (string, error) { + var setting SystemSetting + err := s.db.WithContext(ctx).First(&setting, "key = ?", key).Error + if err != nil { + return "", err + } + return setting.Value, nil +} + +func (s *Store) SetSystemSetting(ctx context.Context, key, value string) error { + setting := SystemSetting{Key: key, Value: value} + return s.db.WithContext(ctx).Save(&setting).Error +} + // ScheduledTask operations func (s *Store) CreateScheduledTask(ctx context.Context, task *ScheduledTask) error { if task.ID == "" { diff --git a/internal/docker/client.go b/internal/docker/client.go index 53d14fc..4d3df67 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -440,7 +440,7 @@ func (c *Client) CreateContainer(ctx context.Context, server *models.Server, ser hostConfig := &container.HostConfig{ PortBindings: portBindings, Mounts: []mount.Mount{ - {Type: mount.TypeBind, Source: dataPath, Target: "/data"}, + {Type: mount.TypeBind, Source: dataPath, Target: "/data", BindOptions: &mount.BindOptions{CreateMountpoint: true}}, }, RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, Resources: container.Resources{ diff --git a/internal/docker/module.go b/internal/docker/module.go index c0ece50..8783e9b 100644 --- a/internal/docker/module.go +++ b/internal/docker/module.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" - "path/filepath" shellparse "github.com/arkady-emelyanov/go-shellparse" "github.com/docker/docker/api/types/container" @@ -225,31 +223,12 @@ func (c *Client) parseVolumeMounts(volumeJSON string, aliasCtx *alias.Context) [ continue } - // Handle path translation when DiscoPanel runs in a container - if mountType == mount.TypeBind { - if envHostDataPath := os.Getenv("DISCOPANEL_HOST_DATA_PATH"); envHostDataPath != "" { - containerDataDir := os.Getenv("DISCOPANEL_DATA_DIR") - if containerDataDir == "" { - containerDataDir = "/app/data" - } - if relPath, err := filepath.Rel(containerDataDir, source); err == nil { - source = filepath.Join(envHostDataPath, relPath) - } - } - } - - // Ensure source directory exists for bind mounts - if mountType == mount.TypeBind { - if err := os.MkdirAll(source, 0755); err != nil { - c.log.Warn("Failed to create volume source directory %s: %v", source, err) - } - } - mounts = append(mounts, mount.Mount{ - Type: mountType, - Source: source, - Target: target, - ReadOnly: vol.ReadOnly, + Type: mountType, + Source: source, + Target: target, + ReadOnly: vol.ReadOnly, + BindOptions: &mount.BindOptions{CreateMountpoint: !vol.ReadOnly}, }) } diff --git a/internal/indexers/errors.go b/internal/indexers/errors.go new file mode 100644 index 0000000..7cb2818 --- /dev/null +++ b/internal/indexers/errors.go @@ -0,0 +1,147 @@ +package indexers + +import ( + "errors" + "fmt" + "net" +) + +// indexer errors +type ErrorKind int + +const ( + ErrAuth ErrorKind = iota // 401/403 or missing API key + ErrRateLimit // 429 + ErrNotFound // 404 + ErrNetwork // DNS failure, timeout, connection refused + ErrAPI // Other non-2xx status codes + ErrDecode // JSON decode failures +) + +func (k ErrorKind) String() string { + switch k { + case ErrAuth: + return "authentication error" + case ErrRateLimit: + return "rate limited" + case ErrNotFound: + return "not found" + case ErrNetwork: + return "network error" + case ErrAPI: + return "API error" + case ErrDecode: + return "decode error" + default: + return "unknown error" + } +} + +type IndexerError struct { + Kind ErrorKind + Indexer string + StatusCode int + URL string + Body string + Err error +} + +func (e *IndexerError) Error() string { + base := fmt.Sprintf("%s: %s", e.Indexer, e.Kind) + + if e.StatusCode != 0 { + base = fmt.Sprintf("%s (status %d)", base, e.StatusCode) + } + if e.URL != "" { + base = fmt.Sprintf("%s url=%s", base, e.URL) + } + if e.Err != nil { + base = fmt.Sprintf("%s: %v", base, e.Err) + } + if e.Body != "" { + base = fmt.Sprintf("%s body=%s", base, e.Body) + } + return base +} + +func (e *IndexerError) Unwrap() error { + return e.Err +} + +// It automatically classifies 401/403 as Auth, 404 as NotFound, 429 as RateLimit. +func NewAPIError(indexer string, statusCode int, url string, body string) *IndexerError { + kind := ErrAPI + switch { + case statusCode == 401 || statusCode == 403: + kind = ErrAuth + case statusCode == 404: + kind = ErrNotFound + case statusCode == 429: + kind = ErrRateLimit + } + return &IndexerError{ + Kind: kind, + Indexer: indexer, + StatusCode: statusCode, + URL: url, + Body: body, + } +} + +// IndexerError for network-level failures like dns +func NewNetworkError(indexer string, url string, err error) *IndexerError { + wrapped := err + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + wrapped = fmt.Errorf("DNS lookup failed for %s: %w", dnsErr.Name, err) + } + return &IndexerError{ + Kind: ErrNetwork, + Indexer: indexer, + URL: url, + Err: wrapped, + } +} + +// JSON decode failures. +func NewDecodeError(indexer string, url string, err error) *IndexerError { + return &IndexerError{ + Kind: ErrDecode, + Indexer: indexer, + URL: url, + Err: err, + } +} + +// Missing API key configuration +func NewAuthConfigError(indexer string, msg string) *IndexerError { + return &IndexerError{ + Kind: ErrAuth, + Indexer: indexer, + Err: errors.New(msg), + } +} + +// Reports whether the error is a rate-limit error +func IsRateLimit(err error) bool { + var ie *IndexerError + return errors.As(err, &ie) && ie.Kind == ErrRateLimit +} + +// Reports whether the error is an auth error +func IsAuthError(err error) bool { + var ie *IndexerError + return errors.As(err, &ie) && ie.Kind == ErrAuth +} + +// Reports whether the error is a not-found error +func IsNotFound(err error) bool { + var ie *IndexerError + return errors.As(err, &ie) && ie.Kind == ErrNotFound +} + +// Reports whether the error is a network error +func IsNetworkError(err error) bool { + var ie *IndexerError + return errors.As(err, &ie) && ie.Kind == ErrNetwork +} diff --git a/internal/indexers/fuego/adapter.go b/internal/indexers/fuego/adapter.go index 980aada..a820155 100644 --- a/internal/indexers/fuego/adapter.go +++ b/internal/indexers/fuego/adapter.go @@ -8,8 +8,15 @@ import ( "github.com/nickheyer/discopanel/internal/config" "github.com/nickheyer/discopanel/internal/indexers" + "github.com/nickheyer/discopanel/pkg/utils" ) +func init() { + indexers.RegisterIndexer("fuego", func(apiKey string, cfg *config.Config) indexers.ModpackIndexer { + return NewIndexer(apiKey, cfg) + }) +} + // Implements ModpackIndexer var _ indexers.ModpackIndexer = (*FuegoIndexer)(nil) @@ -141,8 +148,8 @@ func (f *FuegoIndexer) convertModpack(fm Modpack) indexers.Modpack { } // Deduplicate - gameVersions = deduplicateStrings(gameVersions) - modLoaders = deduplicateStrings(modLoaders) + gameVersions = utils.DeduplicateStrings(gameVersions) + modLoaders = utils.DeduplicateStrings(modLoaders) logoURL := "" if fm.Logo.ThumbnailURL != "" { @@ -210,17 +217,3 @@ func (f *FuegoIndexer) convertFile(file File, modpackID string) indexers.Modpack ServerPackFileID: &serverPackID, } } - -func deduplicateStrings(strings []string) []string { - seen := make(map[string]bool) - result := []string{} - - for _, str := range strings { - if !seen[str] { - seen[str] = true - result = append(result, str) - } - } - - return result -} diff --git a/internal/indexers/fuego/fuego.go b/internal/indexers/fuego/fuego.go index 5bd7624..1a1762b 100644 --- a/internal/indexers/fuego/fuego.go +++ b/internal/indexers/fuego/fuego.go @@ -2,15 +2,13 @@ package fuego import ( "context" - "encoding/json" "fmt" - "io" - "net/http" "net/url" "strconv" "time" "github.com/nickheyer/discopanel/internal/config" + "github.com/nickheyer/discopanel/internal/indexers" ) const ( @@ -20,18 +18,16 @@ const ( ) type Client struct { - apiKey string - config *config.Config - httpClient *http.Client + apiKey string + http *indexers.HTTPClient } func NewClient(apiKey string, cfg *config.Config) *Client { return &Client{ apiKey: apiKey, - config: cfg, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, + http: indexers.NewHTTPClient("fuego", cfg.Server.UserAgent, map[string]string{ + "x-api-key": apiKey, + }), } } @@ -171,7 +167,7 @@ const ( func (c *Client) SearchModpacks(ctx context.Context, query string, gameVersion string, modLoader ModLoaderType, index, pageSize int) (*SearchModsResponse, error) { if c.apiKey == "" { - return nil, fmt.Errorf("fuego API key not configured") + return nil, indexers.NewAuthConfigError("fuego", "API key not configured") } params := url.Values{} @@ -194,27 +190,8 @@ func (c *Client) SearchModpacks(ctx context.Context, query string, gameVersion s params.Set("modLoaderType", strconv.Itoa(int(modLoader))) } - req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/mods/search?%s", BaseURL, params.Encode()), nil) - if err != nil { - return nil, err - } - - req.Header.Set("x-api-key", c.apiKey) - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", c.config.Server.UserAgent) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, c.formatError(req, resp) - } - var result SearchModsResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + if err := c.http.DoJSON(ctx, fmt.Sprintf("%s/mods/search?%s", BaseURL, params.Encode()), &result); err != nil { return nil, err } @@ -223,32 +200,13 @@ func (c *Client) SearchModpacks(ctx context.Context, query string, gameVersion s func (c *Client) GetModpackFiles(ctx context.Context, modID int) ([]File, error) { if c.apiKey == "" { - return nil, fmt.Errorf("fuego API key not configured") - } - - req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/mods/%d/files", BaseURL, modID), nil) - if err != nil { - return nil, err - } - - req.Header.Set("x-api-key", c.apiKey) - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", c.config.Server.UserAgent) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, c.formatError(req, resp) + return nil, indexers.NewAuthConfigError("fuego", "API key not configured") } var result struct { Data []File `json:"data"` } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + if err := c.http.DoJSON(ctx, fmt.Sprintf("%s/mods/%d/files", BaseURL, modID), &result); err != nil { return nil, err } @@ -257,43 +215,15 @@ func (c *Client) GetModpackFiles(ctx context.Context, modID int) ([]File, error) func (c *Client) GetModpack(ctx context.Context, modID int) (*Modpack, error) { if c.apiKey == "" { - return nil, fmt.Errorf("fuego API key not configured") - } - - req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/mods/%d", BaseURL, modID), nil) - if err != nil { - return nil, err - } - - req.Header.Set("x-api-key", c.apiKey) - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", c.config.Server.UserAgent) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, c.formatError(req, resp) + return nil, indexers.NewAuthConfigError("fuego", "API key not configured") } var result struct { Data Modpack `json:"data"` } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + if err := c.http.DoJSON(ctx, fmt.Sprintf("%s/mods/%d", BaseURL, modID), &result); err != nil { return nil, err } return &result.Data, nil } - -func (c *Client) formatError(req *http.Request, resp *http.Response) error { - bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - body := string(bodyBytes) - if body != "" { - return fmt.Errorf("fuego API error: %s (url=%s body=%s)", resp.Status, req.URL.String(), body) - } - return fmt.Errorf("fuego API error: %s (url=%s)", resp.Status, req.URL.String()) -} diff --git a/internal/indexers/httpclient.go b/internal/indexers/httpclient.go new file mode 100644 index 0000000..5a4f05d --- /dev/null +++ b/internal/indexers/httpclient.go @@ -0,0 +1,63 @@ +package indexers + +import ( + "context" + "encoding/json" + "io" + "net/http" + "time" +) + +// HTTPClient wraps http.Client with common indexer request logic. +type HTTPClient struct { + client *http.Client + userAgent string + indexer string + extraHeaders map[string]string +} + +// NewHTTPClient creates a shared HTTP client for an indexer. +func NewHTTPClient(indexer string, userAgent string, extraHeaders map[string]string) *HTTPClient { + return &HTTPClient{ + client: &http.Client{ + Timeout: 30 * time.Second, + }, + userAgent: userAgent, + indexer: indexer, + extraHeaders: extraHeaders, + } +} + +// DoJSON performs a GET request, checks the status, and JSON-decodes into dest. +// It returns structured IndexerErrors for all failure modes. +func (h *HTTPClient) DoJSON(ctx context.Context, url string, dest any) error { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return &IndexerError{Kind: ErrNetwork, Indexer: h.indexer, URL: url, Err: err} + } + + req.Header.Set("Accept", "application/json") + if h.userAgent != "" { + req.Header.Set("User-Agent", h.userAgent) + } + for k, v := range h.extraHeaders { + req.Header.Set(k, v) + } + + resp, err := h.client.Do(req) + if err != nil { + return NewNetworkError(h.indexer, url, err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return NewAPIError(h.indexer, resp.StatusCode, url, string(bodyBytes)) + } + + if err := json.NewDecoder(resp.Body).Decode(dest); err != nil { + return NewDecodeError(h.indexer, url, err) + } + + return nil +} diff --git a/internal/indexers/indexer.go b/internal/indexers/indexer.go index 9853f65..2bad4fb 100644 --- a/internal/indexers/indexer.go +++ b/internal/indexers/indexer.go @@ -2,7 +2,11 @@ package indexers import ( "context" + "fmt" + "sync" "time" + + "github.com/nickheyer/discopanel/internal/config" ) // ModpackIndexer defines the interface for modpack indexing services @@ -65,3 +69,30 @@ type ModpackFile struct { SortIndex int `json:"sort_index"` VersionNumber string `json:"version_number"` // Human-readable version for Modrinth } + +// IndexerFactory creates a ModpackIndexer from an API key and config. +type IndexerFactory func(apiKey string, cfg *config.Config) ModpackIndexer + +var ( + registryMu sync.RWMutex + registry = make(map[string]IndexerFactory) +) + +// RegisterIndexer registers an IndexerFactory under the given name. +// Typically called from an indexer package's init() function. +func RegisterIndexer(name string, factory IndexerFactory) { + registryMu.Lock() + defer registryMu.Unlock() + registry[name] = factory +} + +// NewIndexer creates a ModpackIndexer by name using the factory registry. +func NewIndexer(name string, apiKey string, cfg *config.Config) (ModpackIndexer, error) { + registryMu.RLock() + factory, ok := registry[name] + registryMu.RUnlock() + if !ok { + return nil, fmt.Errorf("unknown indexer: %s", name) + } + return factory(apiKey, cfg), nil +} diff --git a/internal/indexers/modrinth/adapter.go b/internal/indexers/modrinth/adapter.go index 51a241e..94d40e0 100644 --- a/internal/indexers/modrinth/adapter.go +++ b/internal/indexers/modrinth/adapter.go @@ -10,6 +10,12 @@ import ( "github.com/nickheyer/discopanel/internal/indexers" ) +func init() { + indexers.RegisterIndexer("modrinth", func(_ string, cfg *config.Config) indexers.ModpackIndexer { + return NewIndexer(cfg) + }) +} + // Implements ModpackIndexer var _ indexers.ModpackIndexer = (*ModrinthIndexer)(nil) diff --git a/internal/indexers/modrinth/client.go b/internal/indexers/modrinth/client.go index 3763d46..e4ae42b 100644 --- a/internal/indexers/modrinth/client.go +++ b/internal/indexers/modrinth/client.go @@ -4,14 +4,12 @@ import ( "context" "encoding/json" "fmt" - "io" - "net/http" "net/url" "strconv" "strings" - "time" "github.com/nickheyer/discopanel/internal/config" + "github.com/nickheyer/discopanel/internal/indexers" ) const ( @@ -19,16 +17,12 @@ const ( ) type Client struct { - config *config.Config - httpClient *http.Client + http *indexers.HTTPClient } func NewClient(cfg *config.Config) *Client { return &Client{ - config: cfg, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, + http: indexers.NewHTTPClient("modrinth", cfg.Server.UserAgent, nil), } } @@ -169,17 +163,14 @@ func (c *Client) SearchModpacks(ctx context.Context, query string, gameVersion s } if modLoader != "" { - // Modrinth uses categories for loaders facets = append(facets, []string{fmt.Sprintf("categories:%s", strings.ToLower(modLoader))}) } - // Convert facets to JSON string facetsJSON, err := json.Marshal(facets) if err != nil { return nil, fmt.Errorf("failed to marshal facets: %w", err) } - // Build URL with query parameters params := url.Values{} if query != "" { params.Set("query", query) @@ -189,33 +180,9 @@ func (c *Client) SearchModpacks(ctx context.Context, query string, gameVersion s params.Set("offset", strconv.Itoa(offset)) params.Set("limit", strconv.Itoa(limit)) - reqURL := fmt.Sprintf("%s/search?%s", BaseURL, params.Encode()) - - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("User-Agent", c.config.Server.UserAgent) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer func() { - err := resp.Body.Close() - if err != nil { - fmt.Printf("failed to close response body: %v", err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, c.formatError(req, resp) - } - var searchResp SearchResponse - if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.http.DoJSON(ctx, fmt.Sprintf("%s/search?%s", BaseURL, params.Encode()), &searchResp); err != nil { + return nil, err } return &searchResp, nil @@ -223,33 +190,9 @@ func (c *Client) SearchModpacks(ctx context.Context, query string, gameVersion s // GetModpack retrieves detailed information about a specific modpack func (c *Client) GetModpack(ctx context.Context, modpackID string) (*ProjectDetails, error) { - reqURL := fmt.Sprintf("%s/project/%s", BaseURL, modpackID) - - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("User-Agent", c.config.Server.UserAgent) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer func() { - err := resp.Body.Close() - if err != nil { - fmt.Printf("failed to close response body: %v", err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, c.formatError(req, resp) - } - var project ProjectDetails - if err := json.NewDecoder(resp.Body).Decode(&project); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.http.DoJSON(ctx, fmt.Sprintf("%s/project/%s", BaseURL, modpackID), &project); err != nil { + return nil, err } return &project, nil @@ -257,43 +200,10 @@ func (c *Client) GetModpack(ctx context.Context, modpackID string) (*ProjectDeta // GetModpackVersions retrieves all versions for a specific modpack func (c *Client) GetModpackVersions(ctx context.Context, modpackID string) ([]Version, error) { - reqURL := fmt.Sprintf("%s/project/%s/version", BaseURL, modpackID) - - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("User-Agent", c.config.Server.UserAgent) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer func() { - err := resp.Body.Close() - if err != nil { - fmt.Printf("failed to close response body: %v", err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, c.formatError(req, resp) - } - var versions []Version - if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.http.DoJSON(ctx, fmt.Sprintf("%s/project/%s/version", BaseURL, modpackID), &versions); err != nil { + return nil, err } return versions, nil } - -func (c *Client) formatError(req *http.Request, resp *http.Response) error { - bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - body := string(bodyBytes) - if body != "" { - return fmt.Errorf("modrinth API error: %s (url=%s body=%s)", resp.Status, req.URL.String(), body) - } - return fmt.Errorf("modrinth API error: %s (url=%s)", resp.Status, req.URL.String()) -} diff --git a/internal/rbac/mapping.go b/internal/rbac/mapping.go new file mode 100644 index 0000000..55b2ac3 --- /dev/null +++ b/internal/rbac/mapping.go @@ -0,0 +1,175 @@ +package rbac + +// ProcedurePermission maps an RPC procedure to a resource and action. +type ProcedurePermission struct { + Resource string + Action string + ObjectIDField string // Protobuf field name to extract for per-object RBAC (empty = "*") +} + +// PublicProcedures lists RPC procedures that require no authentication. +var PublicProcedures = map[string]bool{ + "/discopanel.v1.AuthService/GetAuthStatus": true, + "/discopanel.v1.AuthService/Login": true, + "/discopanel.v1.AuthService/Register": true, + "/discopanel.v1.AuthService/GetOIDCLoginURL": true, + "/discopanel.v1.AuthService/ValidateInvite": true, +} + +// AuthenticatedOnlyProcedures lists RPC procedures that require authentication +// but no specific resource permission. +var AuthenticatedOnlyProcedures = map[string]bool{ + // AuthService - authenticated user operations + "/discopanel.v1.AuthService/GetCurrentUser": true, + "/discopanel.v1.AuthService/Logout": true, + "/discopanel.v1.AuthService/ChangePassword": true, + "/discopanel.v1.AuthService/CreateAPIToken": true, + "/discopanel.v1.AuthService/ListAPITokens": true, + "/discopanel.v1.AuthService/DeleteAPIToken": true, + + // MinecraftService - reference data, no resource ownership + "/discopanel.v1.MinecraftService/GetMinecraftVersions": true, + "/discopanel.v1.MinecraftService/GetModLoaders": true, + "/discopanel.v1.MinecraftService/GetDockerImages": true, +} + +// ProcedurePermissions maps each RPC procedure path to the resource and action +// required to invoke it, plus an optional ObjectIDField for per-object scoping. +var ProcedurePermissions = map[string]ProcedurePermission{ + // ── ServerService ────────────────────────────────────────────────── + "/discopanel.v1.ServerService/ListServers": {Resource: ResourceServers, Action: ActionRead}, + "/discopanel.v1.ServerService/GetServer": {Resource: ResourceServers, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/GetServerLogs": {Resource: ResourceServers, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/ClearServerLogs": {Resource: ResourceServers, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/GetNextAvailablePort": {Resource: ResourceServers, Action: ActionRead}, + "/discopanel.v1.ServerService/CreateServer": {Resource: ResourceServers, Action: ActionCreate}, + "/discopanel.v1.ServerService/UpdateServer": {Resource: ResourceServers, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/DeleteServer": {Resource: ResourceServers, Action: ActionDelete, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/StartServer": {Resource: ResourceServers, Action: ActionStart, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/StopServer": {Resource: ResourceServers, Action: ActionStop, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/RestartServer": {Resource: ResourceServers, Action: ActionRestart, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/RecreateServer": {Resource: ResourceServers, Action: ActionRestart, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/SendCommand": {Resource: ResourceServers, Action: ActionCommand, ObjectIDField: "id"}, + + // ── AuthService (admin) ─────────────────────────────────────────── + "/discopanel.v1.AuthService/GetAuthConfig": {Resource: ResourceSettings, Action: ActionRead}, + "/discopanel.v1.AuthService/UpdateAuthSettings": {Resource: ResourceSettings, Action: ActionUpdate}, + "/discopanel.v1.AuthService/CreateInvite": {Resource: ResourceUsers, Action: ActionCreate}, + "/discopanel.v1.AuthService/ListInvites": {Resource: ResourceUsers, Action: ActionRead}, + "/discopanel.v1.AuthService/GetInvite": {Resource: ResourceUsers, Action: ActionRead}, + "/discopanel.v1.AuthService/DeleteInvite": {Resource: ResourceUsers, Action: ActionDelete}, + + // ── ConfigService ────────────────────────────────────────────────── + "/discopanel.v1.ConfigService/GetServerConfig": {Resource: ResourceServerConfig, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.ConfigService/UpdateServerConfig": {Resource: ResourceServerConfig, Action: ActionUpdate, ObjectIDField: "server_id"}, + "/discopanel.v1.ConfigService/GetGlobalSettings": {Resource: ResourceSettings, Action: ActionRead}, + "/discopanel.v1.ConfigService/UpdateGlobalSettings": {Resource: ResourceSettings, Action: ActionUpdate}, + + // ── FileService ──────────────────────────────────────────────────── + "/discopanel.v1.FileService/ListFiles": {Resource: ResourceFiles, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.FileService/GetFile": {Resource: ResourceFiles, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.FileService/SaveUploadedFile": {Resource: ResourceFiles, Action: ActionCreate, ObjectIDField: "server_id"}, + "/discopanel.v1.FileService/UpdateFile": {Resource: ResourceFiles, Action: ActionUpdate, ObjectIDField: "server_id"}, + "/discopanel.v1.FileService/DeleteFile": {Resource: ResourceFiles, Action: ActionDelete, ObjectIDField: "server_id"}, + "/discopanel.v1.FileService/RenameFile": {Resource: ResourceFiles, Action: ActionUpdate, ObjectIDField: "server_id"}, + "/discopanel.v1.FileService/ExtractArchive": {Resource: ResourceFiles, Action: ActionUpdate, ObjectIDField: "server_id"}, + + // ── ModService ───────────────────────────────────────────────────── + "/discopanel.v1.ModService/ListMods": {Resource: ResourceMods, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.ModService/GetMod": {Resource: ResourceMods, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.ModService/ImportUploadedMod": {Resource: ResourceMods, Action: ActionCreate, ObjectIDField: "server_id"}, + "/discopanel.v1.ModService/UpdateMod": {Resource: ResourceMods, Action: ActionUpdate, ObjectIDField: "server_id"}, + "/discopanel.v1.ModService/DeleteMod": {Resource: ResourceMods, Action: ActionDelete, ObjectIDField: "server_id"}, + + // ── ModpackService ───────────────────────────────────────────────── + "/discopanel.v1.ModpackService/SearchModpacks": {Resource: ResourceModpacks, Action: ActionRead}, + "/discopanel.v1.ModpackService/GetModpack": {Resource: ResourceModpacks, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ModpackService/GetModpackBySlug": {Resource: ResourceModpacks, Action: ActionRead}, + "/discopanel.v1.ModpackService/GetModpackByURL": {Resource: ResourceModpacks, Action: ActionRead}, + "/discopanel.v1.ModpackService/SyncModpacks": {Resource: ResourceModpacks, Action: ActionCreate}, + "/discopanel.v1.ModpackService/ImportUploadedModpack": {Resource: ResourceModpacks, Action: ActionCreate}, + "/discopanel.v1.ModpackService/DeleteModpack": {Resource: ResourceModpacks, Action: ActionDelete, ObjectIDField: "id"}, + "/discopanel.v1.ModpackService/ToggleFavorite": {Resource: ResourceModpacks, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.ModpackService/ListFavorites": {Resource: ResourceModpacks, Action: ActionRead}, + "/discopanel.v1.ModpackService/GetIndexerStatus": {Resource: ResourceModpacks, Action: ActionRead}, + "/discopanel.v1.ModpackService/GetModpackConfig": {Resource: ResourceModpacks, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ModpackService/GetModpackFiles": {Resource: ResourceModpacks, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ModpackService/GetModpackVersions": {Resource: ResourceModpacks, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ModpackService/SyncModpackFiles": {Resource: ResourceModpacks, Action: ActionUpdate, ObjectIDField: "id"}, + + // ── ModuleService ────────────────────────────────────────────────── + "/discopanel.v1.ModuleService/ListModuleTemplates": {Resource: ResourceModuleTemplates, Action: ActionRead}, + "/discopanel.v1.ModuleService/GetModuleTemplate": {Resource: ResourceModuleTemplates, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/CreateModuleTemplate": {Resource: ResourceModuleTemplates, Action: ActionCreate}, + "/discopanel.v1.ModuleService/UpdateModuleTemplate": {Resource: ResourceModuleTemplates, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/DeleteModuleTemplate": {Resource: ResourceModuleTemplates, Action: ActionDelete, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/ListModules": {Resource: ResourceModules, Action: ActionRead}, + "/discopanel.v1.ModuleService/GetModule": {Resource: ResourceModules, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/CreateModule": {Resource: ResourceModules, Action: ActionCreate, ObjectIDField: "server_id"}, + "/discopanel.v1.ModuleService/UpdateModule": {Resource: ResourceModules, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/DeleteModule": {Resource: ResourceModules, Action: ActionDelete, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/StartModule": {Resource: ResourceModules, Action: ActionStart, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/StopModule": {Resource: ResourceModules, Action: ActionStop, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/RestartModule": {Resource: ResourceModules, Action: ActionRestart, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/RecreateModule": {Resource: ResourceModules, Action: ActionRestart, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/GetModuleLogs": {Resource: ResourceModules, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/GetNextAvailableModulePort": {Resource: ResourceModules, Action: ActionRead}, + "/discopanel.v1.ModuleService/GetAvailableAliases": {Resource: ResourceModules, Action: ActionRead}, + "/discopanel.v1.ModuleService/GetResolvedAliases": {Resource: ResourceModules, Action: ActionRead}, + + // ── ProxyService ─────────────────────────────────────────────────── + "/discopanel.v1.ProxyService/GetProxyRoutes": {Resource: ResourceProxy, Action: ActionRead}, + "/discopanel.v1.ProxyService/GetProxyStatus": {Resource: ResourceProxy, Action: ActionRead}, + "/discopanel.v1.ProxyService/UpdateProxyConfig": {Resource: ResourceProxy, Action: ActionUpdate}, + "/discopanel.v1.ProxyService/GetProxyListeners": {Resource: ResourceProxy, Action: ActionRead}, + "/discopanel.v1.ProxyService/CreateProxyListener": {Resource: ResourceProxy, Action: ActionCreate}, + "/discopanel.v1.ProxyService/UpdateProxyListener": {Resource: ResourceProxy, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.ProxyService/DeleteProxyListener": {Resource: ResourceProxy, Action: ActionDelete, ObjectIDField: "id"}, + "/discopanel.v1.ProxyService/GetServerRouting": {Resource: ResourceProxy, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.ProxyService/UpdateServerRouting": {Resource: ResourceProxy, Action: ActionUpdate, ObjectIDField: "server_id"}, + + // ── TaskService ──────────────────────────────────────────────────── + "/discopanel.v1.TaskService/ListTasks": {Resource: ResourceTasks, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.TaskService/GetTask": {Resource: ResourceTasks, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.TaskService/CreateTask": {Resource: ResourceTasks, Action: ActionCreate, ObjectIDField: "server_id"}, + "/discopanel.v1.TaskService/UpdateTask": {Resource: ResourceTasks, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.TaskService/DeleteTask": {Resource: ResourceTasks, Action: ActionDelete, ObjectIDField: "id"}, + "/discopanel.v1.TaskService/ToggleTask": {Resource: ResourceTasks, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.TaskService/TriggerTask": {Resource: ResourceTasks, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.TaskService/ListTaskExecutions": {Resource: ResourceTasks, Action: ActionRead, ObjectIDField: "task_id"}, + "/discopanel.v1.TaskService/ListServerExecutions": {Resource: ResourceTasks, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.TaskService/GetTaskExecution": {Resource: ResourceTasks, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.TaskService/CancelExecution": {Resource: ResourceTasks, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.TaskService/GetSchedulerStatus": {Resource: ResourceTasks, Action: ActionRead}, + + // ── UserService ──────────────────────────────────────────────────── + "/discopanel.v1.UserService/ListUsers": {Resource: ResourceUsers, Action: ActionRead}, + "/discopanel.v1.UserService/GetUser": {Resource: ResourceUsers, Action: ActionRead}, + "/discopanel.v1.UserService/CreateUser": {Resource: ResourceUsers, Action: ActionCreate}, + "/discopanel.v1.UserService/UpdateUser": {Resource: ResourceUsers, Action: ActionUpdate}, + "/discopanel.v1.UserService/DeleteUser": {Resource: ResourceUsers, Action: ActionDelete}, + + // ── RoleService ──────────────────────────────────────────────────── + "/discopanel.v1.RoleService/ListRoles": {Resource: ResourceRoles, Action: ActionRead}, + "/discopanel.v1.RoleService/GetRole": {Resource: ResourceRoles, Action: ActionRead}, + "/discopanel.v1.RoleService/CreateRole": {Resource: ResourceRoles, Action: ActionCreate}, + "/discopanel.v1.RoleService/UpdateRole": {Resource: ResourceRoles, Action: ActionUpdate}, + "/discopanel.v1.RoleService/DeleteRole": {Resource: ResourceRoles, Action: ActionDelete}, + "/discopanel.v1.RoleService/GetPermissionMatrix": {Resource: ResourceRoles, Action: ActionRead}, + "/discopanel.v1.RoleService/UpdatePermissions": {Resource: ResourceRoles, Action: ActionUpdate}, + "/discopanel.v1.RoleService/AssignRole": {Resource: ResourceRoles, Action: ActionCreate}, + "/discopanel.v1.RoleService/UnassignRole": {Resource: ResourceRoles, Action: ActionDelete}, + "/discopanel.v1.RoleService/GetUserRoles": {Resource: ResourceRoles, Action: ActionRead}, + + // ── SupportService ───────────────────────────────────────────────── + "/discopanel.v1.SupportService/GenerateSupportBundle": {Resource: ResourceSupport, Action: ActionCreate}, + "/discopanel.v1.SupportService/DownloadSupportBundle": {Resource: ResourceSupport, Action: ActionRead}, + "/discopanel.v1.SupportService/UploadSupportBundle": {Resource: ResourceSupport, Action: ActionCreate}, + "/discopanel.v1.SupportService/GetApplicationLogs": {Resource: ResourceSupport, Action: ActionRead}, + + // ── UploadService ────────────────────────────────────────────────── + "/discopanel.v1.UploadService/GetUploadStatus": {Resource: ResourceUploads, Action: ActionRead}, + "/discopanel.v1.UploadService/InitUpload": {Resource: ResourceUploads, Action: ActionCreate}, + "/discopanel.v1.UploadService/UploadChunk": {Resource: ResourceUploads, Action: ActionCreate}, + "/discopanel.v1.UploadService/CancelUpload": {Resource: ResourceUploads, Action: ActionDelete}, +} diff --git a/internal/rbac/rbac.go b/internal/rbac/rbac.go new file mode 100644 index 0000000..9084de0 --- /dev/null +++ b/internal/rbac/rbac.go @@ -0,0 +1,197 @@ +package rbac + +import ( + "fmt" + "strings" + + "github.com/casbin/casbin/v3" + "github.com/casbin/casbin/v3/model" + gormadapter "github.com/casbin/gorm-adapter/v3" + "gorm.io/gorm" +) + +// Permission represents a single resource/action/object permission tuple. +type Permission struct { + Resource string + Action string + ObjectID string +} + +// Enforcer wraps a Casbin enforcer with convenience methods for RBAC. +type Enforcer struct { + enforcer *casbin.Enforcer +} + +// NewEnforcer creates a new Casbin RBAC enforcer backed by the given GORM database. +func NewEnforcer(db *gorm.DB) (*Enforcer, error) { + adapter, err := gormadapter.NewAdapterByDB(db) + if err != nil { + return nil, fmt.Errorf("failed to create casbin adapter: %w", err) + } + + // RBAC model with resource/action/object_id + m, err := model.NewModelFromString(` +[request_definition] +r = sub, res, act, obj + +[policy_definition] +p = sub, res, act, obj + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && (p.res == "*" || r.res == p.res) && (p.act == "*" || r.act == p.act) && (p.obj == "*" || r.obj == p.obj) +`) + if err != nil { + return nil, fmt.Errorf("failed to create casbin model: %w", err) + } + + e, err := casbin.NewEnforcer(m, adapter) + if err != nil { + return nil, fmt.Errorf("failed to create casbin enforcer: %w", err) + } + + if err := e.LoadPolicy(); err != nil { + return nil, fmt.Errorf("failed to load casbin policy: %w", err) + } + + return &Enforcer{enforcer: e}, nil +} + +// Ensures default roles have their base permissions +func (e *Enforcer) SeedDefaultPolicies(anonymousEnabled bool) error { + policies := map[string][][]string{ + "admin": { + {"admin", "*", "*", "*"}, + }, + "user": { + {"user", ResourceServers, ActionRead, "*"}, + {"user", ResourceServers, ActionStart, "*"}, + {"user", ResourceServers, ActionStop, "*"}, + {"user", ResourceServers, ActionRestart, "*"}, + {"user", ResourceServers, ActionCommand, "*"}, + {"user", ResourceServerConfig, ActionRead, "*"}, + {"user", ResourceMods, ActionRead, "*"}, + {"user", ResourceModpacks, ActionRead, "*"}, + {"user", ResourceModules, ActionRead, "*"}, + {"user", ResourceModuleTemplates, ActionRead, "*"}, + {"user", ResourceFiles, ActionRead, "*"}, + {"user", ResourceTasks, ActionRead, "*"}, + {"user", ResourceProxy, ActionRead, "*"}, + }, + "anonymous": { + {"anonymous", ResourceServers, ActionRead, "*"}, + {"anonymous", ResourceServerConfig, ActionRead, "*"}, + {"anonymous", ResourceMods, ActionRead, "*"}, + {"anonymous", ResourceModpacks, ActionRead, "*"}, + {"anonymous", ResourceModules, ActionRead, "*"}, + {"anonymous", ResourceModuleTemplates, ActionRead, "*"}, + {"anonymous", ResourceFiles, ActionRead, "*"}, + {"anonymous", ResourceTasks, ActionRead, "*"}, + {"anonymous", ResourceProxy, ActionRead, "*"}, + }, + } + + for role, rolePolicies := range policies { + existing, err := e.enforcer.GetFilteredPolicy(0, role) + if err != nil { + return err + } + if len(existing) > 0 { + continue + } + + for _, p := range rolePolicies { + if _, err := e.enforcer.AddPolicy(p[0], p[1], p[2], p[3]); err != nil { + return err + } + } + } + + return e.enforcer.SavePolicy() +} + +// Enforce checks if any of the given roles allows the specified action on a +// resource with the given object ID. Returns true on the first matching role. +func (e *Enforcer) Enforce(roles []string, resource, action, objectID string) (bool, error) { + for _, role := range roles { + allowed, err := e.enforcer.Enforce(role, resource, action, objectID) + if err != nil { + return false, err + } + if allowed { + return true, nil + } + } + return false, nil +} + +// GetPermissionsForRole returns all permissions currently assigned to the role. +func (e *Enforcer) GetPermissionsForRole(role string) []Permission { + policies, err := e.enforcer.GetFilteredPolicy(0, role) + if err != nil { + return nil + } + perms := make([]Permission, 0, len(policies)) + for _, p := range policies { + if len(p) >= 4 { + perms = append(perms, Permission{ + Resource: p[1], + Action: p[2], + ObjectID: p[3], + }) + } + } + return perms +} + +// SetPermissionsForRole replaces all permissions for a role atomically. +// The admin role cannot be modified. +func (e *Enforcer) SetPermissionsForRole(role string, perms []Permission) error { + // Don't modify admin role + if strings.ToLower(role) == "admin" { + return fmt.Errorf("cannot modify admin role permissions") + } + + // Remove existing permissions + e.enforcer.RemoveFilteredPolicy(0, role) + + // Add new permissions + for _, p := range perms { + objectID := p.ObjectID + if objectID == "" { + objectID = "*" + } + _, err := e.enforcer.AddPolicy(role, p.Resource, p.Action, objectID) + if err != nil { + return err + } + } + + return e.enforcer.SavePolicy() +} + +// GetPermissionMatrix returns a map of role names to their permission slices, +// covering all roles that have any policy defined. +func (e *Enforcer) GetPermissionMatrix() map[string][]Permission { + policies, err := e.enforcer.GetPolicy() + if err != nil { + return nil + } + matrix := make(map[string][]Permission) + for _, p := range policies { + if len(p) >= 4 { + role := p[0] + matrix[role] = append(matrix[role], Permission{ + Resource: p[1], + Action: p[2], + ObjectID: p[3], + }) + } + } + return matrix +} diff --git a/internal/rbac/resources.go b/internal/rbac/resources.go new file mode 100644 index 0000000..b2bc950 --- /dev/null +++ b/internal/rbac/resources.go @@ -0,0 +1,97 @@ +package rbac + +// Resource constants +const ( + ResourceServers = "servers" + ResourceServerConfig = "server_config" + ResourceMods = "mods" + ResourceModpacks = "modpacks" + ResourceModules = "modules" + ResourceModuleTemplates = "module_templates" + ResourceFiles = "files" + ResourceTasks = "tasks" + ResourceProxy = "proxy" + ResourceUsers = "users" + ResourceRoles = "roles" + ResourceSettings = "settings" + ResourceSupport = "support" + ResourceUploads = "uploads" +) + +// Action constants +const ( + ActionRead = "read" + ActionCreate = "create" + ActionUpdate = "update" + ActionDelete = "delete" + ActionStart = "start" + ActionStop = "stop" + ActionRestart = "restart" + ActionCommand = "command" +) + +// ResourceActionEntry pairs a resource with its valid actions. +type ResourceActionEntry struct { + Resource string + Actions []string +} + +// AllActions in display order. +var AllActions = []string{ + ActionRead, ActionCreate, ActionUpdate, ActionDelete, + ActionStart, ActionStop, ActionRestart, ActionCommand, +} + +// AllResources in display order. +var AllResources = []string{ + ResourceServers, ResourceServerConfig, ResourceMods, + ResourceModpacks, ResourceModules, ResourceModuleTemplates, + ResourceFiles, ResourceTasks, ResourceProxy, + ResourceUsers, ResourceRoles, ResourceSettings, + ResourceSupport, ResourceUploads, +} + +// ResourceScopeSource maps each scopeable resource to the resource that +// provides its scope objects. For example, files are scoped by server_id, +// so ResourceFiles → ResourceServers. Resources absent from this map +// (users, roles, settings, support, uploads) have no per-object scoping. +var ResourceScopeSource = map[string]string{ + ResourceServers: ResourceServers, + ResourceServerConfig: ResourceServers, + ResourceFiles: ResourceServers, + ResourceMods: ResourceServers, + ResourceModules: ResourceModules, + ResourceModuleTemplates: ResourceModuleTemplates, + ResourceModpacks: ResourceModpacks, + ResourceProxy: ResourceProxy, + ResourceTasks: ResourceTasks, +} + +// ResourceActionsFromProcedures derives which actions are valid for each +// resource by inspecting the ProcedurePermissions mapping. Maintains stable +// resource ordering via AllResources and stable action ordering via AllActions. +func ResourceActionsFromProcedures() []ResourceActionEntry { + actionSet := make(map[string]map[string]bool) + for _, pp := range ProcedurePermissions { + if actionSet[pp.Resource] == nil { + actionSet[pp.Resource] = make(map[string]bool) + } + actionSet[pp.Resource][pp.Action] = true + } + + entries := make([]ResourceActionEntry, 0, len(AllResources)) + for _, res := range AllResources { + acts, ok := actionSet[res] + if !ok { + continue + } + var ordered []string + for _, a := range AllActions { + if acts[a] { + ordered = append(ordered, a) + } + } + entries = append(entries, ResourceActionEntry{Resource: res, Actions: ordered}) + } + return entries +} diff --git a/internal/rpc/handlers/openapi.go b/internal/rpc/handlers/openapi.go new file mode 100644 index 0000000..109101a --- /dev/null +++ b/internal/rpc/handlers/openapi.go @@ -0,0 +1,149 @@ +package handlers + +import ( + "io/fs" + "net/http" + "strings" + "sync" + + "github.com/nickheyer/discopanel/internal/rbac" + "github.com/nickheyer/discopanel/pkg/logger" + web "github.com/nickheyer/discopanel/web/discopanel" + "gopkg.in/yaml.v3" +) + +// NewOpenAPIHandler returns an http.HandlerFunc that serves the OpenAPI spec. +// Strips Connect protocol noise and injects per-operation security overrides. +// When isAuthEnabled returns false, security schemes are removed entirely. +func NewOpenAPIHandler(log *logger.Logger, isAuthEnabled func() bool) http.HandlerFunc { + var ( + once sync.Once + authEnabled []byte + authDisabled []byte + ) + + return func(w http.ResponseWriter, r *http.Request) { + once.Do(func() { + buildFS, err := web.BuildFS() + if err != nil { + log.Error("Failed to get frontend FS for OpenAPI spec: %v", err) + return + } + + raw, err := fs.ReadFile(buildFS, "schemav1.yaml") + if err != nil { + log.Error("Failed to read OpenAPI spec: %v", err) + return + } + + var doc map[string]any + if err := yaml.Unmarshal(raw, &doc); err != nil { + log.Error("Failed to parse OpenAPI spec: %v", err) + authEnabled = raw + authDisabled = raw + return + } + + // Clean up generated spec + if paths, ok := doc["paths"].(map[string]any); ok { + for path, pathItem := range paths { + methods, ok := pathItem.(map[string]any) + if !ok { + continue + } + for _, methodVal := range methods { + op, ok := methodVal.(map[string]any) + if !ok { + continue + } + // Strip Connect-* header parameters + if params, ok := op["parameters"].([]any); ok { + filtered := params[:0] + for _, p := range params { + pm, ok := p.(map[string]any) + if !ok { + filtered = append(filtered, p) + continue + } + name, _ := pm["name"].(string) + if name == "Connect-Protocol-Version" || name == "Connect-Timeout-Ms" { + continue + } + filtered = append(filtered, p) + } + if len(filtered) == 0 { + delete(op, "parameters") + } else { + op["parameters"] = filtered + } + } + // Mark public operations as no-auth + procedure := "/" + strings.TrimPrefix(path, "/") + if rbac.PublicProcedures[procedure] { + op["security"] = []any{} + } + } + } + } + + // Remove Connect-* schema definitions + if components, ok := doc["components"].(map[string]any); ok { + if schemas, ok := components["schemas"].(map[string]any); ok { + delete(schemas, "connect-protocol-version") + delete(schemas, "connect-timeout-header") + } + } + + enabled, err := yaml.Marshal(doc) + if err != nil { + log.Error("Failed to marshal auth-enabled OpenAPI spec: %v", err) + authEnabled = raw + } else { + authEnabled = enabled + } + + // Build auth-disabled variant: strip all security fields + delete(doc, "security") + + if components, ok := doc["components"].(map[string]any); ok { + delete(components, "securitySchemes") + } + + if paths, ok := doc["paths"].(map[string]any); ok { + for _, pathItem := range paths { + if methods, ok := pathItem.(map[string]any); ok { + for _, methodVal := range methods { + if op, ok := methodVal.(map[string]any); ok { + delete(op, "security") + } + } + } + } + } + + stripped, err := yaml.Marshal(doc) + if err != nil { + log.Error("Failed to marshal stripped OpenAPI spec: %v", err) + authDisabled = raw + } else { + authDisabled = stripped + } + }) + + var spec []byte + if isAuthEnabled() { + spec = authEnabled + } else { + spec = authDisabled + } + + if spec == nil { + http.Error(w, "OpenAPI spec not available", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/yaml") + w.Header().Set("Cache-Control", "no-cache") + w.Write(spec) + } +} diff --git a/internal/rpc/server.go b/internal/rpc/server.go index 37d8860..fcda909 100644 --- a/internal/rpc/server.go +++ b/internal/rpc/server.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "fmt" "net/http" "slices" "strings" @@ -16,6 +17,8 @@ import ( "github.com/nickheyer/discopanel/internal/metrics" "github.com/nickheyer/discopanel/internal/module" "github.com/nickheyer/discopanel/internal/proxy" + "github.com/nickheyer/discopanel/internal/rbac" + "github.com/nickheyer/discopanel/internal/rpc/handlers" "github.com/nickheyer/discopanel/internal/rpc/services" "github.com/nickheyer/discopanel/internal/scheduler" "github.com/nickheyer/discopanel/internal/ws" @@ -25,6 +28,8 @@ import ( web "github.com/nickheyer/discopanel/web/discopanel" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" ) // Server represents the Connect RPC server @@ -36,7 +41,8 @@ type Server struct { handler http.Handler proxyManager *proxy.Manager authManager *auth.Manager - authMiddleware *auth.Middleware + enforcer *rbac.Enforcer + oidcHandler *auth.OIDCHandler logStreamer *logger.LogStreamer scheduler *scheduler.Scheduler metricsCollector *metrics.Collector @@ -47,13 +53,28 @@ type Server struct { // Creates new Connect RPC server func NewServer(store *storage.Store, docker *docker.Client, cfg *config.Config, proxyManager *proxy.Manager, sched *scheduler.Scheduler, metricsCollector *metrics.Collector, moduleManager *module.Manager, log *logger.Logger) *Server { + // Initialize RBAC enforcer + enforcer, err := rbac.NewEnforcer(store.DB()) + if err != nil { + log.Error("Failed to initialize RBAC enforcer: %v", err) + } + if enforcer != nil { + if err := enforcer.SeedDefaultPolicies(cfg.Auth.AnonymousAccess); err != nil { + log.Error("Failed to seed default policies: %v", err) + } + } + // Initialize auth manager - authManager := auth.NewManager(store) - authMiddleware := auth.NewMiddleware(authManager, store) + authManager, err := auth.NewManager(store, enforcer, &cfg.Auth) + if err != nil { + log.Error("Failed to initialize auth manager: %v", err) + } - // Initialize auth on startup - if err := authManager.InitializeAuth(context.Background()); err != nil { - log.Error("Failed to initialize authentication: %v", err) + // Initialize OIDC handler + oidcHandler, err := auth.NewOIDCHandler(authManager, store, &cfg.Auth.OIDC, log) + if err != nil { + log.Warn("Failed to initialize OIDC handler: %v", err) + oidcHandler, _ = auth.NewOIDCHandler(authManager, store, &config.OIDCConfig{}, log) } // Initialize log streamer @@ -65,7 +86,7 @@ func NewServer(store *storage.Store, docker *docker.Client, cfg *config.Config, uploadManager := upload.NewManager(cfg.Storage.TempDir, uploadTTL, cfg.Upload.MaxUploadSize, log) // Initialize WebSocket hub - wsHub := ws.NewHub(logStreamer, authManager, store, docker, log) + wsHub := ws.NewHub(logStreamer, authManager, enforcer, store, docker, log) go wsHub.Run() s := &Server{ @@ -75,7 +96,8 @@ func NewServer(store *storage.Store, docker *docker.Client, cfg *config.Config, log: log, proxyManager: proxyManager, authManager: authManager, - authMiddleware: authMiddleware, + enforcer: enforcer, + oidcHandler: oidcHandler, logStreamer: logStreamer, scheduler: sched, metricsCollector: metricsCollector, @@ -119,6 +141,7 @@ func (s *Server) setupHandler() { discopanelv1connect.ModpackServiceName, discopanelv1connect.ModuleServiceName, discopanelv1connect.ProxyServiceName, + discopanelv1connect.RoleServiceName, discopanelv1connect.ServerServiceName, discopanelv1connect.SupportServiceName, discopanelv1connect.TaskServiceName, @@ -131,6 +154,15 @@ func (s *Server) setupHandler() { // Register WebSocket handler mux.Handle("/ws", s.wsHub) + // Register OIDC HTTP handlers + if s.oidcHandler != nil && s.oidcHandler.IsEnabled() { + mux.HandleFunc("/api/v1/auth/oidc/login", s.oidcHandler.HandleLogin) + mux.HandleFunc("/api/v1/auth/oidc/callback", s.oidcHandler.HandleCallback) + } + + // Serve dynamic OpenAPI spec + mux.HandleFunc("/api/v1/openapi.yaml", handlers.NewOpenAPIHandler(s.log, s.authManager.IsAnyAuthEnabled)) + // Serve frontend for non-RPC routes s.setupFrontend(mux) @@ -141,7 +173,7 @@ func (s *Server) setupHandler() { // Registers all Connect RPC service handlers func (s *Server) registerServices(mux *http.ServeMux, opts []connect.HandlerOption) { // Create service instances - authService := services.NewAuthService(s.store, s.authManager, s.log) + authService := services.NewAuthService(s.store, s.authManager, s.enforcer, s.oidcHandler, s.log) configService := services.NewConfigService(s.store, s.config, s.docker, s.log) fileService := services.NewFileService(s.store, s.docker, s.uploadManager, s.log) minecraftService := services.NewMinecraftService(s.store, s.docker, s.log) @@ -152,6 +184,7 @@ func (s *Server) registerServices(mux *http.ServeMux, opts []connect.HandlerOpti supportService := services.NewSupportService(s.store, s.docker, s.config, s.log) taskService := services.NewTaskService(s.store, s.scheduler, s.log) userService := services.NewUserService(s.store, s.authManager, s.log) + roleService := services.NewRoleService(s.store, s.enforcer, s.log) moduleService := services.NewModuleService(s.store, s.docker, s.moduleManager, s.proxyManager, s.config, s.logStreamer, s.log) uploadService := services.NewUploadService(s.uploadManager, s.config, s.log) @@ -189,6 +222,9 @@ func (s *Server) registerServices(mux *http.ServeMux, opts []connect.HandlerOpti userPath, userHandler := discopanelv1connect.NewUserServiceHandler(userService, opts...) mux.Handle(userPath, userHandler) + rolePath, roleHandler := discopanelv1connect.NewRoleServiceHandler(roleService, opts...) + mux.Handle(rolePath, roleHandler) + modulePath, moduleHandler := discopanelv1connect.NewModuleServiceHandler(moduleService, opts...) mux.Handle(modulePath, moduleHandler) @@ -214,23 +250,81 @@ func (s *Server) loggingInterceptor() connect.UnaryInterceptorFunc { } } -// Creates a Connect interceptor for authentication +// Creates a Connect interceptor for authentication and authorization func (s *Server) authInterceptor() connect.UnaryInterceptorFunc { return func(next connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - // Extract token from auth header + procedure := req.Spec().Procedure + + // Public procedures - no auth required + if rbac.PublicProcedures[procedure] { + return next(ctx, req) + } + + // If no auth providers are enabled, bypass auth entirely - grant full admin access + if !s.authManager.IsAnyAuthEnabled() { + superUser := &auth.AuthenticatedUser{ + ID: "admin", + Username: "admin", + Roles: []string{"admin"}, + Provider: "none", + } + ctx = auth.WithUser(ctx, superUser) + return next(ctx, req) + } + + // Extract token from Authorization header token := "" if authHeader := req.Header().Get("Authorization"); authHeader != "" { - token, _ = strings.CutPrefix(authHeader, "Bearer ") - token, _ = strings.CutPrefix(token, "bearer ") + token = strings.TrimPrefix(strings.TrimPrefix(authHeader, "Bearer "), "bearer ") } - // Validate user session or return valid anon user/session if auth disabled - user, err := s.authManager.ValidateSession(ctx, token) - if err == nil && user != nil { - ctx = context.WithValue(ctx, auth.UserContextKey, user) - } else if err != nil { - s.log.Debug("Auth: Token validation failed for %s: %v", req.Spec().Procedure, err) + var user *auth.AuthenticatedUser + + if token != "" { + var err error + if strings.HasPrefix(token, "dp_") { + // API token authentication + user, err = s.authManager.ValidateAPIToken(ctx, token) + } else { + // Session/JWT authentication + user, err = s.authManager.ValidateSession(ctx, token) + } + if err != nil { + s.log.Debug("Auth: Token validation failed for %s: %v", procedure, err) + return nil, connect.NewError(connect.CodeUnauthenticated, err) + } + } else if s.authManager.IsAnonymousAccessEnabled() { + // Anonymous access + user = s.authManager.AnonymousUser() + } else { + return nil, connect.NewError(connect.CodeUnauthenticated, auth.ErrInvalidToken) + } + + // Set user in context + ctx = auth.WithUser(ctx, user) + + // Authenticated-only procedures (no specific resource permission needed) + if rbac.AuthenticatedOnlyProcedures[procedure] { + return next(ctx, req) + } + + // Check resource permission + if perm, ok := rbac.ProcedurePermissions[procedure]; ok { + if s.enforcer != nil { + objectID := "*" + if perm.ObjectIDField != "" { + objectID = extractObjectID(req, perm.ObjectIDField) + } + allowed, err := s.enforcer.Enforce(user.Roles, perm.Resource, perm.Action, objectID) + if err != nil { + s.log.Error("RBAC enforcement error: %v", err) + return nil, connect.NewError(connect.CodeInternal, err) + } + if !allowed { + return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("insufficient permissions for %s/%s", perm.Resource, perm.Action)) + } + } } return next(ctx, req) @@ -238,19 +332,20 @@ func (s *Server) authInterceptor() connect.UnaryInterceptorFunc { } } +// pollingProcedures lists endpoints that are called frequently and should be excluded from logging. +var pollingProcedures = []string{ + "/discopanel.v1.AuthService/GetAuthStatus", + "/discopanel.v1.ServerService/ListServers", + "/discopanel.v1.ServerService/GetServer", + "/discopanel.v1.ServerService/GetServerLogs", + "/discopanel.v1.ProxyService/GetProxyStatus", + "/discopanel.v1.SupportService/GetApplicationLogs", + "/discopanel.v1.UploadService/UploadChunk", + "/discopanel.v1.UploadService/GetUploadStatus", +} + // Checks if a procedure is a polling endpoint or high-frequency endpoint func (s *Server) isPollingProcedure(procedure string) bool { - pollingProcedures := []string{ - "/discopanel.v1.AuthService/GetAuthStatus", - "/discopanel.v1.ServerService/ListServers", - "/discopanel.v1.ServerService/GetServer", - "/discopanel.v1.ServerService/GetServerLogs", - "/discopanel.v1.ProxyService/GetProxyStatus", - "/discopanel.v1.SupportService/GetApplicationLogs", - "/discopanel.v1.UploadService/UploadChunk", - "/discopanel.v1.UploadService/GetUploadStatus", - } - return slices.Contains(pollingProcedures, procedure) } @@ -286,7 +381,7 @@ func (s *Server) createFrontendHandler(fs http.FileSystem) http.HandlerFunc { return } - // Try to serve the file + // Try to serve the file directly (static assets like JS, CSS, images) path := r.URL.Path if path == "/" { path = "/index.html" @@ -331,6 +426,24 @@ func isConnectPath(path string) bool { return false } +// extractObjectID extracts a named string field from a protobuf request message +// using reflection. Falls back to "*" if the field is missing or empty. +func extractObjectID(req connect.AnyRequest, fieldName string) string { + msg, ok := req.Any().(proto.Message) + if !ok { + return "*" + } + fd := msg.ProtoReflect().Descriptor().Fields().ByName(protoreflect.Name(fieldName)) + if fd == nil { + return "*" + } + val := msg.ProtoReflect().Get(fd) + if str := val.String(); str != "" { + return str + } + return "*" +} + // Starts log streaming for a container func (s *Server) StartLogStreaming(containerID string) error { return s.logStreamer.StartStreaming(containerID) diff --git a/internal/rpc/services/auth.go b/internal/rpc/services/auth.go index a64f9d4..9a2d420 100644 --- a/internal/rpc/services/auth.go +++ b/internal/rpc/services/auth.go @@ -2,421 +2,558 @@ package services import ( "context" + "crypto/rand" + "encoding/base64" "errors" - "net/http" + "strings" "time" "connectrpc.com/connect" + "golang.org/x/crypto/bcrypt" + "github.com/nickheyer/discopanel/internal/auth" storage "github.com/nickheyer/discopanel/internal/db" + "github.com/nickheyer/discopanel/internal/rbac" "github.com/nickheyer/discopanel/pkg/logger" v1 "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1" "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1/discopanelv1connect" "google.golang.org/protobuf/types/known/timestamppb" ) -// Compile-time check that AuthService implements the interface var _ discopanelv1connect.AuthServiceHandler = (*AuthService)(nil) -// AuthService implements the Auth service type AuthService struct { store *storage.Store authManager *auth.Manager + enforcer *rbac.Enforcer + oidcHandler *auth.OIDCHandler log *logger.Logger } -// NewAuthService creates a new auth service -func NewAuthService(store *storage.Store, authManager *auth.Manager, log *logger.Logger) *AuthService { +func NewAuthService(store *storage.Store, authManager *auth.Manager, enforcer *rbac.Enforcer, oidcHandler *auth.OIDCHandler, log *logger.Logger) *AuthService { return &AuthService{ store: store, authManager: authManager, + enforcer: enforcer, + oidcHandler: oidcHandler, log: log, } } -// Helper functions for auth service - -// dbUserToProto converts a database User to a proto User -func dbUserToProto(user *storage.User) *v1.User { - if user == nil { - return nil - } - - protoUser := &v1.User{ - Id: user.ID, - Username: user.Username, - IsActive: user.IsActive, - CreatedAt: timestamppb.New(user.CreatedAt), - UpdatedAt: timestamppb.New(user.UpdatedAt), - } - - // Map role - switch user.Role { - case storage.RoleAdmin: - protoUser.Role = v1.UserRole_USER_ROLE_ADMIN - case storage.RoleEditor: - protoUser.Role = v1.UserRole_USER_ROLE_EDITOR - case storage.RoleViewer: - protoUser.Role = v1.UserRole_USER_ROLE_VIEWER - default: - protoUser.Role = v1.UserRole_USER_ROLE_UNSPECIFIED - } - - // Handle optional email - if user.Email != nil && *user.Email != "" { - protoUser.Email = user.Email - } - - // Note: recovery_key is intentionally not included in the proto response for security reasons - // It should only be shown to the user when first created - - return protoUser -} - -// protoRoleToDBRole converts a proto UserRole to a DB UserRole -func protoRoleToDBRole(role v1.UserRole) storage.UserRole { - switch role { - case v1.UserRole_USER_ROLE_ADMIN: - return storage.RoleAdmin - case v1.UserRole_USER_ROLE_EDITOR: - return storage.RoleEditor - case v1.UserRole_USER_ROLE_VIEWER: - return storage.RoleViewer - default: - return storage.RoleViewer - } -} - -// extractTokenFromHeaders extracts the auth token from the request headers -func extractTokenFromHeaders(headers http.Header) string { - // Try Authorization header first - authHeader := headers.Get("Authorization") - if authHeader != "" && len(authHeader) > 7 && authHeader[:7] == "Bearer " { - return authHeader[7:] - } - - // Try cookie - cookies := headers.Values("Cookie") - for _, cookie := range cookies { - if len(cookie) > 11 && cookie[:11] == "auth_token=" { - // Simple cookie parsing - find the value up to semicolon or end - value := cookie[11:] - if idx := indexByte(value, ';'); idx >= 0 { - value = value[:idx] - } - return value - } - } - - return "" -} - -// indexByte returns the index of the first instance of c in s, or -1 if not present -func indexByte(s string, c byte) int { - for i := 0; i < len(s); i++ { - if s[i] == c { - return i - } - } - return -1 -} - -// GetAuthStatus checks if auth is enabled func (s *AuthService) GetAuthStatus(ctx context.Context, req *connect.Request[v1.GetAuthStatusRequest]) (*connect.Response[v1.GetAuthStatusResponse], error) { - authConfig, _, err := s.store.GetAuthConfig(ctx) - if err != nil { - s.log.Error("Failed to get auth config: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get auth config")) - } - - // Check if this is first user setup userCount, err := s.store.CountUsers(ctx) if err != nil { s.log.Error("Failed to count users: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to check user count")) + userCount = 0 } + oidcEnabled := s.oidcHandler != nil && s.oidcHandler.IsEnabled() + return connect.NewResponse(&v1.GetAuthStatusResponse{ - Enabled: authConfig.Enabled, - FirstUserSetup: userCount == 0, - AllowRegistration: authConfig.AllowRegistration, + LocalAuthEnabled: s.authManager.IsLocalAuthEnabled(), + OidcEnabled: oidcEnabled, + AllowRegistration: s.authManager.IsRegistrationAllowed(), + FirstUserSetup: userCount == 0, + AnonymousAccessEnabled: s.authManager.IsAnonymousAccessEnabled(), }), nil } -// Login authenticates user credentials func (s *AuthService) Login(ctx context.Context, req *connect.Request[v1.LoginRequest]) (*connect.Response[v1.LoginResponse], error) { msg := req.Msg - // Validate input if msg.Username == "" || msg.Password == "" { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("username and password are required")) } - // Attempt login - user, token, err := s.authManager.Login(ctx, msg.Username, msg.Password) + user, roles, token, expiresAt, err := s.authManager.Login(ctx, msg.Username, msg.Password) if err != nil { - switch err { - case auth.ErrInvalidCredentials: + if errors.Is(err, auth.ErrInvalidCredentials) || errors.Is(err, auth.ErrUserNotActive) { return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid credentials")) - case auth.ErrUserNotActive: - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("user account is not active")) - case auth.ErrAuthDisabled: - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("authentication is disabled")) - default: - s.log.Error("Login error: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("login failed")) } + if errors.Is(err, auth.ErrLocalAuthDisabled) { + return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("local authentication is disabled")) + } + s.log.Error("Login failed: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("login failed")) } - // Get auth config for session timeout - authConfig, _, _ := s.store.GetAuthConfig(ctx) - expiresAt := time.Now().Add(time.Duration(authConfig.SessionTimeout) * time.Second) - - // Set cookie in response headers - resp := connect.NewResponse(&v1.LoginResponse{ + return connect.NewResponse(&v1.LoginResponse{ Token: token, - User: dbUserToProto(user), + User: dbUserToProto(user, roles), ExpiresAt: timestamppb.New(expiresAt), - }) - - // Set auth cookie - cookie := &http.Cookie{ - Name: "auth_token", - Value: token, - Path: "/", - Expires: expiresAt, - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - } - resp.Header().Set("Set-Cookie", cookie.String()) - - return resp, nil + }), nil } -// Logout invalidates session token func (s *AuthService) Logout(ctx context.Context, req *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) { - // Extract token from headers - token := extractTokenFromHeaders(req.Header()) + // Extract token from Authorization header + token := "" + if authHeader := req.Header().Get("Authorization"); authHeader != "" { + token, _ = strings.CutPrefix(authHeader, "Bearer ") + token, _ = strings.CutPrefix(token, "bearer ") + } if token != "" { - // Delete session if err := s.authManager.Logout(ctx, token); err != nil { - s.log.Error("Logout error: %v", err) - // Don't fail the logout request even if session deletion fails + s.log.Debug("Logout error: %v", err) } } - // Clear cookie in response - resp := connect.NewResponse(&v1.LogoutResponse{ - Message: "Logged out successfully", - }) - - // Clear auth cookie - cookie := &http.Cookie{ - Name: "auth_token", - Value: "", - Path: "/", - Expires: time.Now().Add(-time.Hour), - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - } - resp.Header().Set("Set-Cookie", cookie.String()) - - return resp, nil + return connect.NewResponse(&v1.LogoutResponse{ + Message: "logged out", + }), nil } -// Register creates a new user account func (s *AuthService) Register(ctx context.Context, req *connect.Request[v1.RegisterRequest]) (*connect.Response[v1.RegisterResponse], error) { msg := req.Msg - // Validate input + userCount, _ := s.store.CountUsers(ctx) + isFirstUser := userCount == 0 + + var invite *storage.RegistrationInvite + + if msg.InviteCode != nil && *msg.InviteCode != "" { + // Validate invite + var err error + invite, err = s.store.GetRegistrationInviteByCode(ctx, *msg.InviteCode) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("invalid invite code")) + } + if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now()) { + return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("invite has expired")) + } + if invite.MaxUses > 0 && invite.UseCount >= invite.MaxUses { + return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("invite has reached maximum uses")) + } + if invite.PinHash != "" { + if msg.InvitePin == nil || *msg.InvitePin == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("PIN is required for this invite")) + } + if err := bcrypt.CompareHashAndPassword([]byte(invite.PinHash), []byte(*msg.InvitePin)); err != nil { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("incorrect PIN")) + } + } + } else if !isFirstUser && !s.authManager.IsRegistrationAllowed() { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("registration is disabled")) + } + if msg.Username == "" || msg.Password == "" { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("username and password are required")) } - // Check if this is first user setup - userCount, err := s.store.CountUsers(ctx) + user, err := s.authManager.CreateLocalUser(ctx, msg.Username, msg.Email, msg.Password) if err != nil { - s.log.Error("Failed to check user count: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to check user count")) + s.log.Error("Registration failed: %v", err) + return nil, connect.NewError(connect.CodeAlreadyExists, errors.New("registration failed")) } - // Determine role - var role storage.UserRole - if userCount == 0 { - // First user is always admin - role = storage.RoleAdmin + // Role assignment: first user → admin; invite with roles → invite roles; else → default roles + if isFirstUser { + _ = s.store.AssignRole(ctx, user.ID, "admin", "local") + } else if invite != nil && len(invite.Roles) > 0 { + for _, roleName := range invite.Roles { + _ = s.store.AssignRole(ctx, user.ID, roleName, "invite") + } } else { - // Check if registration is allowed - authConfig, _, err := s.store.GetAuthConfig(ctx) - if err != nil { - s.log.Error("Failed to get auth config: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get auth config")) + defaultRoles, _ := s.store.GetDefaultRoles(ctx) + for _, role := range defaultRoles { + _ = s.store.AssignRole(ctx, user.ID, role.Name, "local") } + } - if !authConfig.AllowRegistration { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("registration is disabled")) - } + // Increment invite use count after successful registration + if invite != nil { + _ = s.store.IncrementInviteUseCount(ctx, invite.ID) + } + + roles, _ := s.store.GetUserRoleNames(ctx, user.ID) - // New users default to viewer role - role = storage.RoleViewer + return connect.NewResponse(&v1.RegisterResponse{ + User: dbUserToProto(user, roles), + }), nil +} + +func (s *AuthService) GetCurrentUser(ctx context.Context, req *connect.Request[v1.GetCurrentUserRequest]) (*connect.Response[v1.GetCurrentUserResponse], error) { + authUser := auth.GetUserFromContext(ctx) + if authUser == nil { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("not authenticated")) } - // Create user - user, err := s.authManager.CreateUser(ctx, msg.Username, msg.Email, msg.Password, role) - if err != nil { - s.log.Error("Failed to create user: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to create user")) + // When all auth is disabled, the interceptor injects a synthetic admin + // that doesn't exist in DB. Return it directly. + if !s.authManager.IsAnyAuthEnabled() { + roles := authUser.Roles + protoUser := &v1.User{ + Id: authUser.ID, + Username: authUser.Username, + AuthProvider: authUser.Provider, + IsActive: true, + Roles: roles, + } + + var permissions []*v1.Permission + if s.enforcer != nil { + for _, role := range roles { + for _, p := range s.enforcer.GetPermissionsForRole(role) { + permissions = append(permissions, &v1.Permission{ + Resource: p.Resource, + Action: p.Action, + ObjectId: p.ObjectID, + }) + } + } + } + + return connect.NewResponse(&v1.GetCurrentUserResponse{ + User: protoUser, + Permissions: permissions, + }), nil } - // If this was the first user, enable authentication - if userCount == 0 { - authConfig, _, _ := s.store.GetAuthConfig(ctx) - authConfig.Enabled = true - if err := s.store.SaveAuthConfig(ctx, authConfig); err != nil { - s.log.Error("Failed to enable authentication: %v", err) + // Fetch user from db + dbUser, err := s.store.GetUser(ctx, authUser.ID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get user")) + } + + // Fetch roles from db + roles, _ := s.store.GetUserRoleNames(ctx, authUser.ID) + + // Collect permissions from all user roles via the RBAC enforcer + var permissions []*v1.Permission + if s.enforcer != nil { + seen := make(map[string]bool) + for _, role := range roles { + for _, p := range s.enforcer.GetPermissionsForRole(role) { + key := p.Resource + ":" + p.Action + ":" + p.ObjectID + if !seen[key] { + seen[key] = true + permissions = append(permissions, &v1.Permission{ + Resource: p.Resource, + Action: p.Action, + ObjectId: p.ObjectID, + }) + } + } } } - return connect.NewResponse(&v1.RegisterResponse{ - User: dbUserToProto(user), + return connect.NewResponse(&v1.GetCurrentUserResponse{ + User: dbUserToProto(dbUser, roles), + Permissions: permissions, }), nil } -// ResetPassword resets password with recovery key -func (s *AuthService) ResetPassword(ctx context.Context, req *connect.Request[v1.ResetPasswordRequest]) (*connect.Response[v1.ResetPasswordResponse], error) { +func (s *AuthService) ChangePassword(ctx context.Context, req *connect.Request[v1.ChangePasswordRequest]) (*connect.Response[v1.ChangePasswordResponse], error) { + user := auth.GetUserFromContext(ctx) + if user == nil { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("not authenticated")) + } + msg := req.Msg + if msg.OldPassword == "" || msg.NewPassword == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("old and new passwords are required")) + } - if err := s.authManager.ResetPassword(ctx, msg.Username, msg.RecoveryKey, msg.NewPassword); err != nil { - if err == auth.ErrInvalidCredentials { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid recovery key or username")) + if err := s.authManager.ChangePassword(ctx, user.ID, msg.OldPassword, msg.NewPassword); err != nil { + if errors.Is(err, auth.ErrInvalidCredentials) { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("incorrect current password")) } - s.log.Error("Failed to reset password: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to reset password")) + s.log.Error("Change password failed: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to change password")) } - return connect.NewResponse(&v1.ResetPasswordResponse{ - Message: "Password reset successfully", + return connect.NewResponse(&v1.ChangePasswordResponse{ + Message: "password changed", + }), nil +} + +func (s *AuthService) GetOIDCLoginURL(ctx context.Context, req *connect.Request[v1.GetOIDCLoginURLRequest]) (*connect.Response[v1.GetOIDCLoginURLResponse], error) { + if s.oidcHandler == nil || !s.oidcHandler.IsEnabled() { + return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("OIDC is not enabled")) + } + + return connect.NewResponse(&v1.GetOIDCLoginURLResponse{ + LoginUrl: "/api/v1/auth/oidc/login", }), nil } -// GetAuthConfig gets auth configuration func (s *AuthService) GetAuthConfig(ctx context.Context, req *connect.Request[v1.GetAuthConfigRequest]) (*connect.Response[v1.GetAuthConfigResponse], error) { - config, _, err := s.store.GetAuthConfig(ctx) + cfg := s.authManager.GetConfig() + oidcEnabled := s.oidcHandler != nil && s.oidcHandler.IsEnabled() + + userCount, err := s.store.CountUsers(ctx) if err != nil { - s.log.Error("Failed to get auth config: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get auth config")) + s.log.Error("Failed to count users: %v", err) + userCount = 0 } - // If auth is enabled, check for admin permission - if config.Enabled { - user := auth.GetUserFromContext(ctx) - if user == nil || user.Role != storage.RoleAdmin { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("admin access required")) - } + resp := &v1.GetAuthConfigResponse{ + LocalAuthEnabled: cfg.Local.Enabled, + AllowRegistration: cfg.Local.AllowRegistration, + AnonymousAccess: cfg.AnonymousAccess, + SessionTimeout: int32(cfg.SessionTimeout), + OidcEnabled: oidcEnabled, + FirstUserSetup: userCount == 0, } - return connect.NewResponse(&v1.GetAuthConfigResponse{ - Enabled: config.Enabled, - SessionTimeout: int32(config.SessionTimeout), - RequireEmailVerify: config.RequireEmailVerify, - AllowRegistration: config.AllowRegistration, - }), nil + if oidcEnabled { + resp.OidcIssuerUri = &cfg.OIDC.IssuerURI + resp.OidcClientId = &cfg.OIDC.ClientID + resp.OidcRedirectUrl = &cfg.OIDC.RedirectURL + resp.OidcScopes = cfg.OIDC.Scopes + resp.OidcRoleClaim = &cfg.OIDC.RoleClaim + } + + return connect.NewResponse(resp), nil } -// UpdateAuthConfig modifies auth configuration -func (s *AuthService) UpdateAuthConfig(ctx context.Context, req *connect.Request[v1.UpdateAuthConfigRequest]) (*connect.Response[v1.UpdateAuthConfigResponse], error) { +func (s *AuthService) UpdateAuthSettings(ctx context.Context, req *connect.Request[v1.UpdateAuthSettingsRequest]) (*connect.Response[v1.UpdateAuthSettingsResponse], error) { msg := req.Msg - config, _, err := s.store.GetAuthConfig(ctx) + if err := s.authManager.UpdateSettings(ctx, msg.LocalAuthEnabled, msg.AllowRegistration, msg.AnonymousAccess, msg.SessionTimeout); err != nil { + if errors.Is(err, auth.ErrSessionTimeoutMin) { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + s.log.Error("Failed to update auth settings: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to update auth settings")) + } + + // Return the updated config + configResp, err := s.GetAuthConfig(ctx, connect.NewRequest(&v1.GetAuthConfigRequest{})) if err != nil { - s.log.Error("Failed to get auth config: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get auth config")) + return nil, err } - // If auth is currently enabled, require admin permission - if config.Enabled { - user := auth.GetUserFromContext(ctx) - if user == nil || user.Role != storage.RoleAdmin { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("admin access required")) + return connect.NewResponse(&v1.UpdateAuthSettingsResponse{ + Config: configResp.Msg, + }), nil +} + +func (s *AuthService) CreateInvite(ctx context.Context, req *connect.Request[v1.CreateInviteRequest]) (*connect.Response[v1.CreateInviteResponse], error) { + msg := req.Msg + + // Validate roles exist + if len(msg.Roles) > 0 { + existingRoles, err := s.store.ListRoles(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to list roles")) } + roleSet := make(map[string]bool, len(existingRoles)) + for _, r := range existingRoles { + roleSet[r.Name] = true + } + for _, roleName := range msg.Roles { + if !roleSet[roleName] { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role not found: "+roleName)) + } + } + } + + // Generate crypto-random code + codeBytes := make([]byte, 32) + if _, err := rand.Read(codeBytes); err != nil { + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to generate invite code")) } + code := base64.RawURLEncoding.EncodeToString(codeBytes) - // Check if trying to enable auth for the first time - requiresFirstUser := false - if msg.Enabled != nil && *msg.Enabled && !config.Enabled { - // Check if any users exist - userCount, err := s.store.CountUsers(ctx) + // Hash PIN if provided + var pinHash string + if msg.Pin != nil && *msg.Pin != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(*msg.Pin), bcrypt.DefaultCost) if err != nil { - s.log.Error("Failed to check user count: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to check user count")) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to hash PIN")) } + pinHash = string(hash) + } - if userCount == 0 { - // Need to create first admin user - requiresFirstUser = true - return connect.NewResponse(&v1.UpdateAuthConfigResponse{ - Message: "Create an admin account to enable authentication", - RequiresFirstUser: requiresFirstUser, - }), nil - } + // Calculate expiration + var expiresAt *time.Time + if msg.ExpiresInHours != nil && *msg.ExpiresInHours > 0 { + t := time.Now().Add(time.Duration(*msg.ExpiresInHours) * time.Hour) + expiresAt = &t } - // Update allowed fields - if msg.Enabled != nil { - config.Enabled = *msg.Enabled + // Get creator from context + authUser := auth.GetUserFromContext(ctx) + createdBy := "" + if authUser != nil { + createdBy = authUser.Username } - if msg.SessionTimeout != nil { - config.SessionTimeout = int(*msg.SessionTimeout) + + invite := &storage.RegistrationInvite{ + Code: code, + Description: msg.Description, + Roles: msg.Roles, + PinHash: pinHash, + MaxUses: int(msg.MaxUses), + ExpiresAt: expiresAt, + CreatedBy: createdBy, } - if msg.RequireEmailVerify != nil { - config.RequireEmailVerify = *msg.RequireEmailVerify + + if err := s.store.CreateRegistrationInvite(ctx, invite); err != nil { + s.log.Error("Failed to create invite: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to create invite")) } - if msg.AllowRegistration != nil { - config.AllowRegistration = *msg.AllowRegistration + + return connect.NewResponse(&v1.CreateInviteResponse{ + Invite: dbInviteToProto(invite), + }), nil +} + +func (s *AuthService) ListInvites(ctx context.Context, req *connect.Request[v1.ListInvitesRequest]) (*connect.Response[v1.ListInvitesResponse], error) { + invites, err := s.store.ListRegistrationInvites(ctx) + if err != nil { + s.log.Error("Failed to list invites: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to list invites")) } - if err := s.store.SaveAuthConfig(ctx, config); err != nil { - s.log.Error("Failed to update auth config: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to update auth config")) + protoInvites := make([]*v1.RegistrationInvite, 0, len(invites)) + for _, inv := range invites { + protoInvites = append(protoInvites, dbInviteToProto(inv)) } - return connect.NewResponse(&v1.UpdateAuthConfigResponse{ - Message: "Auth config updated successfully", - RequiresFirstUser: false, + return connect.NewResponse(&v1.ListInvitesResponse{ + Invites: protoInvites, }), nil } -// GetCurrentUser gets authenticated user info -func (s *AuthService) GetCurrentUser(ctx context.Context, req *connect.Request[v1.GetCurrentUserRequest]) (*connect.Response[v1.GetCurrentUserResponse], error) { +func (s *AuthService) GetInvite(ctx context.Context, req *connect.Request[v1.GetInviteRequest]) (*connect.Response[v1.GetInviteResponse], error) { + invite, err := s.store.GetRegistrationInvite(ctx, req.Msg.Id) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("invite not found")) + } + + return connect.NewResponse(&v1.GetInviteResponse{ + Invite: dbInviteToProto(invite), + }), nil +} + +func (s *AuthService) DeleteInvite(ctx context.Context, req *connect.Request[v1.DeleteInviteRequest]) (*connect.Response[v1.DeleteInviteResponse], error) { + if err := s.store.DeleteRegistrationInvite(ctx, req.Msg.Id); err != nil { + s.log.Error("Failed to delete invite: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to delete invite")) + } + + return connect.NewResponse(&v1.DeleteInviteResponse{}), nil +} + +func (s *AuthService) ValidateInvite(ctx context.Context, req *connect.Request[v1.ValidateInviteRequest]) (*connect.Response[v1.ValidateInviteResponse], error) { + if req.Msg.Code == "" { + return connect.NewResponse(&v1.ValidateInviteResponse{Valid: false}), nil + } + + invite, err := s.store.GetRegistrationInviteByCode(ctx, req.Msg.Code) + if err != nil { + return connect.NewResponse(&v1.ValidateInviteResponse{Valid: false}), nil + } + + if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now()) { + return connect.NewResponse(&v1.ValidateInviteResponse{Valid: false}), nil + } + + if invite.MaxUses > 0 && invite.UseCount >= invite.MaxUses { + return connect.NewResponse(&v1.ValidateInviteResponse{Valid: false}), nil + } + + return connect.NewResponse(&v1.ValidateInviteResponse{ + Valid: true, + RequiresPin: invite.PinHash != "", + Description: invite.Description, + }), nil +} + +func (s *AuthService) CreateAPIToken(ctx context.Context, req *connect.Request[v1.CreateAPITokenRequest]) (*connect.Response[v1.CreateAPITokenResponse], error) { user := auth.GetUserFromContext(ctx) if user == nil { return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("not authenticated")) } - return connect.NewResponse(&v1.GetCurrentUserResponse{ - User: dbUserToProto(user), + msg := req.Msg + if msg.Name == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("token name is required")) + } + + plaintext, apiToken, err := s.authManager.GenerateAPIToken(ctx, user.ID, msg.Name, msg.ExpiresInDays) + if err != nil { + s.log.Error("Failed to create API token: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to create API token")) + } + + return connect.NewResponse(&v1.CreateAPITokenResponse{ + PlaintextToken: plaintext, + ApiToken: dbAPITokenToProto(apiToken), }), nil } -// ChangePassword changes user's own password -func (s *AuthService) ChangePassword(ctx context.Context, req *connect.Request[v1.ChangePasswordRequest]) (*connect.Response[v1.ChangePasswordResponse], error) { - msg := req.Msg - +func (s *AuthService) ListAPITokens(ctx context.Context, req *connect.Request[v1.ListAPITokensRequest]) (*connect.Response[v1.ListAPITokensResponse], error) { user := auth.GetUserFromContext(ctx) if user == nil { return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("not authenticated")) } - if err := s.authManager.ChangePassword(ctx, user.ID, msg.OldPassword, msg.NewPassword); err != nil { - if err == auth.ErrInvalidCredentials { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid old password")) - } - s.log.Error("Failed to change password: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to change password")) + tokens, err := s.store.ListAPITokensByUser(ctx, user.ID) + if err != nil { + s.log.Error("Failed to list API tokens: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to list API tokens")) } - return connect.NewResponse(&v1.ChangePasswordResponse{ - Message: "Password changed successfully", + protoTokens := make([]*v1.ApiToken, 0, len(tokens)) + for _, t := range tokens { + protoTokens = append(protoTokens, dbAPITokenToProto(&t)) + } + + return connect.NewResponse(&v1.ListAPITokensResponse{ + ApiTokens: protoTokens, }), nil } + +func (s *AuthService) DeleteAPIToken(ctx context.Context, req *connect.Request[v1.DeleteAPITokenRequest]) (*connect.Response[v1.DeleteAPITokenResponse], error) { + user := auth.GetUserFromContext(ctx) + if user == nil { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("not authenticated")) + } + + if req.Msg.Id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("token ID is required")) + } + + if err := s.store.DeleteAPIToken(ctx, req.Msg.Id, user.ID); err != nil { + s.log.Error("Failed to delete API token: %v", err) + return nil, connect.NewError(connect.CodeNotFound, errors.New("API token not found")) + } + + return connect.NewResponse(&v1.DeleteAPITokenResponse{}), nil +} + +func dbAPITokenToProto(t *storage.APIToken) *v1.ApiToken { + pt := &v1.ApiToken{ + Id: t.ID, + Name: t.Name, + CreatedAt: timestamppb.New(t.CreatedAt), + } + if t.ExpiresAt != nil { + pt.ExpiresAt = timestamppb.New(*t.ExpiresAt) + } + if t.LastUsedAt != nil { + pt.LastUsedAt = timestamppb.New(*t.LastUsedAt) + } + return pt +} + +func dbInviteToProto(invite *storage.RegistrationInvite) *v1.RegistrationInvite { + pi := &v1.RegistrationInvite{ + Id: invite.ID, + Code: invite.Code, + Description: invite.Description, + Roles: invite.Roles, + HasPin: invite.PinHash != "", + MaxUses: int32(invite.MaxUses), + UseCount: int32(invite.UseCount), + CreatedBy: invite.CreatedBy, + CreatedAt: timestamppb.New(invite.CreatedAt), + } + if invite.ExpiresAt != nil { + pi.ExpiresAt = timestamppb.New(*invite.ExpiresAt) + } + return pi +} diff --git a/internal/rpc/services/config.go b/internal/rpc/services/config.go index 7fc0191..cbef6a4 100644 --- a/internal/rpc/services/config.go +++ b/internal/rpc/services/config.go @@ -9,7 +9,6 @@ import ( "time" "connectrpc.com/connect" - "github.com/nickheyer/discopanel/internal/auth" "github.com/nickheyer/discopanel/internal/config" storage "github.com/nickheyer/discopanel/internal/db" "github.com/nickheyer/discopanel/internal/docker" @@ -127,10 +126,6 @@ func (s *ConfigService) UpdateServerConfig(ctx context.Context, req *connect.Req // Gets global settings func (s *ConfigService) GetGlobalSettings(ctx context.Context, req *connect.Request[v1.GetGlobalSettingsRequest]) (*connect.Response[v1.GetGlobalSettingsResponse], error) { - if err := s.checkAdminAuth(ctx); err != nil { - return nil, err - } - config, _, err := s.store.GetGlobalSettings(ctx) if err != nil { s.log.Error("Failed to get global settings: %v", err) @@ -150,10 +145,6 @@ func (s *ConfigService) GetGlobalSettings(ctx context.Context, req *connect.Requ // Updates global settings func (s *ConfigService) UpdateGlobalSettings(ctx context.Context, req *connect.Request[v1.UpdateGlobalSettingsRequest]) (*connect.Response[v1.UpdateGlobalSettingsResponse], error) { - if err := s.checkAdminAuth(ctx); err != nil { - return nil, err - } - msg := req.Msg config, _, err := s.store.GetGlobalSettings(ctx) if err != nil { @@ -182,24 +173,6 @@ func (s *ConfigService) UpdateGlobalSettings(ctx context.Context, req *connect.R }), nil } -func (s *ConfigService) checkAdminAuth(ctx context.Context) error { - authConfig, _, err := s.store.GetAuthConfig(ctx) - if err != nil { - return connect.NewError(connect.CodeInternal, errors.New("failed to get auth configuration")) - } - - if authConfig.Enabled { - user := auth.GetUserFromContext(ctx) - if user == nil { - return connect.NewError(connect.CodeUnauthenticated, errors.New("authentication required")) - } - if !auth.CheckPermission(user, storage.RoleAdmin) { - return connect.NewError(connect.CodePermissionDenied, errors.New("admin access required")) - } - } - return nil -} - func (s *ConfigService) recreateContainer(ctx context.Context, server *storage.Server, config *storage.ServerConfig) error { oldContainerID := server.ContainerID wasRunning := false diff --git a/internal/rpc/services/modpack.go b/internal/rpc/services/modpack.go index b22bd90..d579b45 100644 --- a/internal/rpc/services/modpack.go +++ b/internal/rpc/services/modpack.go @@ -4,6 +4,7 @@ import ( "archive/zip" "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -18,8 +19,8 @@ import ( storage "github.com/nickheyer/discopanel/internal/db" "github.com/nickheyer/discopanel/internal/docker" "github.com/nickheyer/discopanel/internal/indexers" - "github.com/nickheyer/discopanel/internal/indexers/fuego" - "github.com/nickheyer/discopanel/internal/indexers/modrinth" + _ "github.com/nickheyer/discopanel/internal/indexers/fuego" + _ "github.com/nickheyer/discopanel/internal/indexers/modrinth" "github.com/nickheyer/discopanel/internal/minecraft" "github.com/nickheyer/discopanel/pkg/files" "github.com/nickheyer/discopanel/pkg/logger" @@ -50,6 +51,46 @@ func NewModpackService(store *storage.Store, cfg *config.Config, uploadManager * } } +// getIndexer creates an indexer by name, looking up the fuego API key from settings when needed. +func (s *ModpackService) getIndexer(ctx context.Context, name string) (indexers.ModpackIndexer, error) { + apiKey := "" + if name == "fuego" { + globalSettings, _, err := s.store.GetGlobalSettings(ctx) + if err != nil || globalSettings == nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get global settings")) + } + if globalSettings.CFAPIKey != nil { + apiKey = *globalSettings.CFAPIKey + } + if apiKey == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("CurseForge API key not configured")) + } + } + idx, err := indexers.NewIndexer(name, apiKey, s.config) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + return idx, nil +} + +// mapIndexerError maps IndexerError kinds to appropriate connect error codes. +func mapIndexerError(err error, msg string) *connect.Error { + var ie *indexers.IndexerError + if errors.As(err, &ie) { + switch ie.Kind { + case indexers.ErrRateLimit: + return connect.NewError(connect.CodeResourceExhausted, fmt.Errorf("%s: %w", msg, err)) + case indexers.ErrAuth: + return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("%s: %w", msg, err)) + case indexers.ErrNotFound: + return connect.NewError(connect.CodeNotFound, fmt.Errorf("%s: %w", msg, err)) + case indexers.ErrNetwork: + return connect.NewError(connect.CodeUnavailable, fmt.Errorf("%s: %w", msg, err)) + } + } + return connect.NewError(connect.CodeInternal, fmt.Errorf("%s: %w", msg, err)) +} + // SearchModpacks searches for modpacks func (s *ModpackService) SearchModpacks(ctx context.Context, req *connect.Request[v1.SearchModpacksRequest]) (*connect.Response[v1.SearchModpacksResponse], error) { msg := req.Msg @@ -318,40 +359,23 @@ func (s *ModpackService) GetModpackVersions(ctx context.Context, req *connect.Re return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("modpack not found")) } - // Get appropriate indexer client - var indexerClient indexers.ModpackIndexer - switch modpack.Indexer { - case "fuego": - // Get API key from global settings - globalSettings, _, err := s.store.GetGlobalSettings(ctx) - if err != nil || globalSettings == nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get global settings")) - } - - apiKey := "" - if globalSettings.CFAPIKey != nil { - apiKey = *globalSettings.CFAPIKey - } - if apiKey == "" { - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("CurseForge API key not configured")) - } - indexerClient = fuego.NewIndexer(apiKey, s.config) - case "modrinth": - indexerClient = modrinth.NewIndexer(s.config) - case "manual": - // For manual modpacks, return empty list + // Manual modpacks have no remote versions + if modpack.Indexer == "manual" { return connect.NewResponse(&v1.GetModpackVersionsResponse{ Versions: []*v1.Version{}, }), nil - default: - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("unknown indexer: %s", modpack.Indexer)) + } + + indexerClient, err := s.getIndexer(ctx, modpack.Indexer) + if err != nil { + return nil, err } // Get files from the indexer files, err := indexerClient.GetModpackFiles(ctx, modpack.IndexerID) if err != nil { s.log.Error("Failed to get modpack files from %s: %v", modpack.Indexer, err) - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get modpack versions")) + return nil, mapIndexerError(err, "failed to get modpack versions") } // Convert files to versions @@ -387,39 +411,16 @@ func (s *ModpackService) SyncModpacks(ctx context.Context, req *connect.Request[ indexer = "fuego" } - var indexerClient indexers.ModpackIndexer - - switch indexer { - case "fuego": - // Get Fuego API key from global settings - globalSettings, _, err := s.store.GetGlobalSettings(ctx) - if err != nil { - s.log.Error("Failed to get global settings: %v", err) - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get global settings")) - } - - apiKey := "" - if globalSettings.CFAPIKey != nil { - apiKey = *globalSettings.CFAPIKey - } - - if apiKey == "" { - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("fuego API key not configured in global settings")) - } - - indexerClient = fuego.NewIndexer(apiKey, s.config) - case "modrinth": - // Modrinth doesn't require an API key for public operations - indexerClient = modrinth.NewIndexer(s.config) - default: - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("unknown indexer: %s", indexer)) + indexerClient, err := s.getIndexer(ctx, indexer) + if err != nil { + return nil, err } // Search modpacks using the indexer searchResp, err := indexerClient.SearchModpacks(ctx, msg.Query, msg.GameVersion, msg.ModLoader, 0, 50) if err != nil { s.log.Error("Failed to search %s: %v", indexer, err) - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to search %s: %w", indexer, err)) + return nil, mapIndexerError(err, fmt.Sprintf("failed to search %s", indexer)) } // Store modpacks in database @@ -903,39 +904,16 @@ func (s *ModpackService) SyncModpackFiles(ctx context.Context, req *connect.Requ return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("modpack not found")) } - var indexerClient indexers.ModpackIndexer - - switch modpack.Indexer { - case "fuego": - // Get Fuego API key from global settings - globalSettings, _, err := s.store.GetGlobalSettings(ctx) - if err != nil { - s.log.Error("Failed to get global settings: %v", err) - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get global settings")) - } - - apiKey := "" - if globalSettings.CFAPIKey != nil { - apiKey = *globalSettings.CFAPIKey - } - - if apiKey == "" { - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("fuego API key not configured in global settings")) - } - - indexerClient = fuego.NewIndexer(apiKey, s.config) - case "modrinth": - // Modrinth doesn't require an API key for public operations - indexerClient = modrinth.NewIndexer(s.config) - default: - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("unknown indexer: %s", modpack.Indexer)) + indexerClient, err := s.getIndexer(ctx, modpack.Indexer) + if err != nil { + return nil, err } // Get files from the indexer files, err := indexerClient.GetModpackFiles(ctx, modpack.IndexerID) if err != nil { s.log.Error("Failed to get modpack files: %v", err) - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get modpack files")) + return nil, mapIndexerError(err, "failed to get modpack files") } // Store files in database diff --git a/internal/rpc/services/role.go b/internal/rpc/services/role.go new file mode 100644 index 0000000..5f3e820 --- /dev/null +++ b/internal/rpc/services/role.go @@ -0,0 +1,424 @@ +package services + +import ( + "context" + "errors" + + "connectrpc.com/connect" + "github.com/google/uuid" + storage "github.com/nickheyer/discopanel/internal/db" + "github.com/nickheyer/discopanel/internal/rbac" + "github.com/nickheyer/discopanel/pkg/logger" + v1 "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1" + "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1/discopanelv1connect" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var _ discopanelv1connect.RoleServiceHandler = (*RoleService)(nil) + +type RoleService struct { + store *storage.Store + enforcer *rbac.Enforcer + log *logger.Logger +} + +func NewRoleService(store *storage.Store, enforcer *rbac.Enforcer, log *logger.Logger) *RoleService { + return &RoleService{ + store: store, + enforcer: enforcer, + log: log, + } +} + +func (s *RoleService) ListRoles(ctx context.Context, req *connect.Request[v1.ListRolesRequest]) (*connect.Response[v1.ListRolesResponse], error) { + roles, err := s.store.ListRoles(ctx) + if err != nil { + s.log.Error("Failed to list roles: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to list roles")) + } + + protoRoles := make([]*v1.Role, 0, len(roles)) + for _, role := range roles { + perms := s.enforcer.GetPermissionsForRole(role.Name) + protoRoles = append(protoRoles, dbRoleToProto(role, perms)) + } + + return connect.NewResponse(&v1.ListRolesResponse{ + Roles: protoRoles, + }), nil +} + +func (s *RoleService) GetRole(ctx context.Context, req *connect.Request[v1.GetRoleRequest]) (*connect.Response[v1.GetRoleResponse], error) { + msg := req.Msg + if msg.Id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role ID is required")) + } + + role, err := s.store.GetRole(ctx, msg.Id) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("role not found")) + } + + perms := s.enforcer.GetPermissionsForRole(role.Name) + + return connect.NewResponse(&v1.GetRoleResponse{ + Role: dbRoleToProto(role, perms), + }), nil +} + +func (s *RoleService) CreateRole(ctx context.Context, req *connect.Request[v1.CreateRoleRequest]) (*connect.Response[v1.CreateRoleResponse], error) { + msg := req.Msg + + if msg.Name == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role name is required")) + } + + role := &storage.Role{ + ID: uuid.New().String(), + Name: msg.Name, + Description: msg.Description, + IsSystem: false, + IsDefault: msg.IsDefault, + } + + if err := s.store.CreateRole(ctx, role); err != nil { + s.log.Error("Failed to create role: %v", err) + return nil, connect.NewError(connect.CodeAlreadyExists, errors.New("failed to create role")) + } + + // Set initial permissions if provided + if len(msg.Permissions) > 0 { + perms := protoPermsToRbac(msg.Permissions) + if err := s.enforcer.SetPermissionsForRole(role.Name, perms); err != nil { + s.log.Error("Failed to set permissions for role %s: %v", role.Name, err) + } + } + + perms := s.enforcer.GetPermissionsForRole(role.Name) + + return connect.NewResponse(&v1.CreateRoleResponse{ + Role: dbRoleToProto(role, perms), + }), nil +} + +func (s *RoleService) UpdateRole(ctx context.Context, req *connect.Request[v1.UpdateRoleRequest]) (*connect.Response[v1.UpdateRoleResponse], error) { + msg := req.Msg + + if msg.Id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role ID is required")) + } + + role, err := s.store.GetRole(ctx, msg.Id) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("role not found")) + } + + if role.IsSystem { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("cannot modify system role")) + } + + if msg.Name != nil { + role.Name = *msg.Name + } + if msg.Description != nil { + role.Description = *msg.Description + } + if msg.IsDefault != nil { + role.IsDefault = *msg.IsDefault + } + + if err := s.store.UpdateRole(ctx, role); err != nil { + s.log.Error("Failed to update role: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to update role")) + } + + perms := s.enforcer.GetPermissionsForRole(role.Name) + + return connect.NewResponse(&v1.UpdateRoleResponse{ + Role: dbRoleToProto(role, perms), + }), nil +} + +func (s *RoleService) DeleteRole(ctx context.Context, req *connect.Request[v1.DeleteRoleRequest]) (*connect.Response[v1.DeleteRoleResponse], error) { + msg := req.Msg + + if msg.Id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role ID is required")) + } + + role, err := s.store.GetRole(ctx, msg.Id) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("role not found")) + } + + if role.IsSystem { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("cannot delete system role")) + } + + // Remove all permissions for this role + _ = s.enforcer.SetPermissionsForRole(role.Name, nil) + + if err := s.store.DeleteRole(ctx, msg.Id); err != nil { + s.log.Error("Failed to delete role: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to delete role")) + } + + return connect.NewResponse(&v1.DeleteRoleResponse{ + Message: "role deleted", + }), nil +} + +func (s *RoleService) GetPermissionMatrix(ctx context.Context, req *connect.Request[v1.GetPermissionMatrixRequest]) (*connect.Response[v1.GetPermissionMatrixResponse], error) { + matrix := s.enforcer.GetPermissionMatrix() + + rolePermsMap := make(map[string]*v1.RolePermissions) + for roleName, perms := range matrix { + protoPerms := make([]*v1.Permission, 0, len(perms)) + for _, p := range perms { + protoPerms = append(protoPerms, &v1.Permission{ + Resource: p.Resource, + Action: p.Action, + ObjectId: p.ObjectID, + }) + } + rolePermsMap[roleName] = &v1.RolePermissions{ + Permissions: protoPerms, + } + } + + // Build resource_actions from procedure mappings + raEntries := rbac.ResourceActionsFromProcedures() + protoRA := make([]*v1.ResourceActions, 0, len(raEntries)) + for _, ra := range raEntries { + protoRA = append(protoRA, &v1.ResourceActions{ + Resource: ra.Resource, + Actions: ra.Actions, + }) + } + + resp := &v1.GetPermissionMatrixResponse{ + ResourceActions: protoRA, + RolePermissions: rolePermsMap, + } + + // Populate available objects for scoped permissions when requested. + // Driven entirely by ProcedurePermissions: any resource with a non-empty + // ObjectIDField is scopeable, and the field name determines which entity + // type provides the objects (e.g. "server_id" → servers). + if req.Msg.IncludeObjects { + type idName struct{ id, name string } + + // Store fetchers keyed by resource constant. + fetchers := map[string]func() []idName{ + rbac.ResourceServers: func() []idName { + items, err := s.store.ListServers(ctx) + if err != nil { + return nil + } + out := make([]idName, len(items)) + for i, x := range items { + out[i] = idName{x.ID, x.Name} + } + return out + }, + rbac.ResourceModules: func() []idName { + items, err := s.store.ListModules(ctx) + if err != nil { + return nil + } + out := make([]idName, len(items)) + for i, x := range items { + out[i] = idName{x.ID, x.Name} + } + return out + }, + rbac.ResourceModuleTemplates: func() []idName { + items, err := s.store.ListModuleTemplates(ctx) + if err != nil { + return nil + } + out := make([]idName, len(items)) + for i, x := range items { + out[i] = idName{x.ID, x.Name} + } + return out + }, + rbac.ResourceProxy: func() []idName { + items, err := s.store.GetProxyListeners(ctx) + if err != nil { + return nil + } + out := make([]idName, len(items)) + for i, x := range items { + out[i] = idName{x.ID, x.Name} + } + return out + }, + rbac.ResourceTasks: func() []idName { + items, err := s.store.ListAllScheduledTasks(ctx) + if err != nil { + return nil + } + out := make([]idName, len(items)) + for i, x := range items { + out[i] = idName{x.ID, x.Name} + } + return out + }, + rbac.ResourceModpacks: func() []idName { + items, _, err := s.store.ListIndexedModpacks(ctx, 0, -1) + if err != nil { + return nil + } + out := make([]idName, len(items)) + for i, x := range items { + out[i] = idName{x.ID, x.Name} + } + return out + }, + } + + // Collect needed source resources and fetch each once. + fetched := make(map[string][]idName) + needed := make(map[string]bool) + for _, res := range rbac.AllResources { + if source, ok := rbac.ResourceScopeSource[res]; ok { + needed[source] = true + } + } + for src := range needed { + if fn, ok := fetchers[src]; ok { + fetched[src] = fn() + } + } + + // Emit ScopeableObjects in stable resource order. + var objects []*v1.ScopeableObject + for _, resource := range rbac.AllResources { + source, ok := rbac.ResourceScopeSource[resource] + if !ok { + continue + } + for _, obj := range fetched[source] { + objects = append(objects, &v1.ScopeableObject{ + Id: obj.id, + Name: obj.name, + Resource: resource, + ScopeSource: source, + }) + } + } + resp.AvailableObjects = objects + } + + return connect.NewResponse(resp), nil +} + +func (s *RoleService) UpdatePermissions(ctx context.Context, req *connect.Request[v1.UpdatePermissionsRequest]) (*connect.Response[v1.UpdatePermissionsResponse], error) { + msg := req.Msg + + if msg.RoleName == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role name is required")) + } + + perms := protoPermsToRbac(msg.Permissions) + + if err := s.enforcer.SetPermissionsForRole(msg.RoleName, perms); err != nil { + s.log.Error("Failed to update permissions for role %s: %v", msg.RoleName, err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to update permissions")) + } + + return connect.NewResponse(&v1.UpdatePermissionsResponse{ + Message: "permissions updated", + }), nil +} + +func (s *RoleService) AssignRole(ctx context.Context, req *connect.Request[v1.AssignRoleRequest]) (*connect.Response[v1.AssignRoleResponse], error) { + msg := req.Msg + + if msg.UserId == "" || msg.RoleName == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user ID and role name are required")) + } + + if err := s.store.AssignRole(ctx, msg.UserId, msg.RoleName, "local"); err != nil { + s.log.Error("Failed to assign role: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to assign role")) + } + + return connect.NewResponse(&v1.AssignRoleResponse{ + Message: "role assigned", + }), nil +} + +func (s *RoleService) UnassignRole(ctx context.Context, req *connect.Request[v1.UnassignRoleRequest]) (*connect.Response[v1.UnassignRoleResponse], error) { + msg := req.Msg + + if msg.UserId == "" || msg.RoleName == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user ID and role name are required")) + } + + if err := s.store.UnassignRole(ctx, msg.UserId, msg.RoleName); err != nil { + s.log.Error("Failed to unassign role: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to unassign role")) + } + + return connect.NewResponse(&v1.UnassignRoleResponse{ + Message: "role unassigned", + }), nil +} + +func (s *RoleService) GetUserRoles(ctx context.Context, req *connect.Request[v1.GetUserRolesRequest]) (*connect.Response[v1.GetUserRolesResponse], error) { + msg := req.Msg + + if msg.UserId == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user ID is required")) + } + + roles, err := s.store.GetUserRoleNames(ctx, msg.UserId) + if err != nil { + s.log.Error("Failed to get user roles: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get user roles")) + } + + return connect.NewResponse(&v1.GetUserRolesResponse{ + Roles: roles, + }), nil +} + +func dbRoleToProto(role *storage.Role, perms []rbac.Permission) *v1.Role { + protoPerms := make([]*v1.Permission, 0, len(perms)) + for _, p := range perms { + protoPerms = append(protoPerms, &v1.Permission{ + Resource: p.Resource, + Action: p.Action, + ObjectId: p.ObjectID, + }) + } + + return &v1.Role{ + Id: role.ID, + Name: role.Name, + Description: role.Description, + IsSystem: role.IsSystem, + IsDefault: role.IsDefault, + Permissions: protoPerms, + CreatedAt: timestamppb.New(role.CreatedAt), + UpdatedAt: timestamppb.New(role.UpdatedAt), + } +} + +func protoPermsToRbac(protoPerms []*v1.Permission) []rbac.Permission { + perms := make([]rbac.Permission, 0, len(protoPerms)) + for _, p := range protoPerms { + objectID := p.ObjectId + if objectID == "" { + objectID = "*" + } + perms = append(perms, rbac.Permission{ + Resource: p.Resource, + Action: p.Action, + ObjectID: objectID, + }) + } + return perms +} diff --git a/internal/rpc/services/user.go b/internal/rpc/services/user.go index 9edacdd..3efcbf1 100644 --- a/internal/rpc/services/user.go +++ b/internal/rpc/services/user.go @@ -10,19 +10,17 @@ import ( "github.com/nickheyer/discopanel/pkg/logger" v1 "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1" "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1/discopanelv1connect" + "google.golang.org/protobuf/types/known/timestamppb" ) -// Compile-time check that UserService implements the interface var _ discopanelv1connect.UserServiceHandler = (*UserService)(nil) -// UserService implements the User service type UserService struct { store *storage.Store authManager *auth.Manager log *logger.Logger } -// NewUserService creates a new user service func NewUserService(store *storage.Store, authManager *auth.Manager, log *logger.Logger) *UserService { return &UserService{ store: store, @@ -31,24 +29,17 @@ func NewUserService(store *storage.Store, authManager *auth.Manager, log *logger } } -// ListUsers lists all users (admin only) func (s *UserService) ListUsers(ctx context.Context, req *connect.Request[v1.ListUsersRequest]) (*connect.Response[v1.ListUsersResponse], error) { - // Check admin permission - user := auth.GetUserFromContext(ctx) - if user == nil || user.Role != storage.RoleAdmin { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("admin access required")) - } - users, err := s.store.ListUsers(ctx) if err != nil { s.log.Error("Failed to list users: %v", err) return nil, connect.NewError(connect.CodeInternal, errors.New("failed to list users")) } - // Convert DB users to proto users - protoUsers := make([]*v1.User, len(users)) - for i, u := range users { - protoUsers[i] = dbUserToProto(u) + protoUsers := make([]*v1.User, 0, len(users)) + for _, user := range users { + roles, _ := s.store.GetUserRoleNames(ctx, user.ID) + protoUsers = append(protoUsers, dbUserToProto(user, roles)) } return connect.NewResponse(&v1.ListUsersResponse{ @@ -56,61 +47,73 @@ func (s *UserService) ListUsers(ctx context.Context, req *connect.Request[v1.Lis }), nil } -// CreateUser creates a new user (admin only) -func (s *UserService) CreateUser(ctx context.Context, req *connect.Request[v1.CreateUserRequest]) (*connect.Response[v1.CreateUserResponse], error) { - // Check admin permission - user := auth.GetUserFromContext(ctx) - if user == nil || user.Role != storage.RoleAdmin { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("admin access required")) +func (s *UserService) GetUser(ctx context.Context, req *connect.Request[v1.GetUserRequest]) (*connect.Response[v1.GetUserResponse], error) { + msg := req.Msg + if msg.Id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user ID is required")) } + user, err := s.store.GetUser(ctx, msg.Id) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("user not found")) + } + + roles, _ := s.store.GetUserRoleNames(ctx, user.ID) + + return connect.NewResponse(&v1.GetUserResponse{ + User: dbUserToProto(user, roles), + }), nil +} + +func (s *UserService) CreateUser(ctx context.Context, req *connect.Request[v1.CreateUserRequest]) (*connect.Response[v1.CreateUserResponse], error) { msg := req.Msg - // Validate role - role := protoRoleToDBRole(msg.Role) - if role != storage.RoleAdmin && role != storage.RoleEditor && role != storage.RoleViewer { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid role")) + if msg.Username == "" || msg.Password == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("username and password are required")) } - // Create user - newUser, err := s.authManager.CreateUser(ctx, msg.Username, msg.Email, msg.Password, role) + user, err := s.authManager.CreateLocalUser(ctx, msg.Username, msg.Email, msg.Password) if err != nil { s.log.Error("Failed to create user: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to create user")) + return nil, connect.NewError(connect.CodeAlreadyExists, errors.New("failed to create user")) + } + + // Assign roles + for _, roleName := range msg.Roles { + if err := s.store.AssignRole(ctx, user.ID, roleName, "local"); err != nil { + s.log.Error("Failed to assign role %s to user %s: %v", roleName, user.ID, err) + } } + // If no roles specified, assign default roles + if len(msg.Roles) == 0 { + defaultRoles, _ := s.store.GetDefaultRoles(ctx) + for _, role := range defaultRoles { + _ = s.store.AssignRole(ctx, user.ID, role.Name, "local") + } + } + + roles, _ := s.store.GetUserRoleNames(ctx, user.ID) + return connect.NewResponse(&v1.CreateUserResponse{ - User: dbUserToProto(newUser), + User: dbUserToProto(user, roles), }), nil } -// UpdateUser updates a user (admin only) func (s *UserService) UpdateUser(ctx context.Context, req *connect.Request[v1.UpdateUserRequest]) (*connect.Response[v1.UpdateUserResponse], error) { - // Check admin permission - currentUser := auth.GetUserFromContext(ctx) - if currentUser == nil || currentUser.Role != storage.RoleAdmin { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("admin access required")) - } - msg := req.Msg - // Get the user to update + if msg.Id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user ID is required")) + } + user, err := s.store.GetUser(ctx, msg.Id) if err != nil { - s.log.Error("Failed to get user: %v", err) return nil, connect.NewError(connect.CodeNotFound, errors.New("user not found")) } - // Update fields if provided if msg.Email != nil { - if *msg.Email == "" { - user.Email = nil // Allow clearing email - } else { - user.Email = msg.Email - } - } - if msg.Role != nil { - user.Role = protoRoleToDBRole(*msg.Role) + user.Email = msg.Email } if msg.IsActive != nil { user.IsActive = *msg.IsActive @@ -121,24 +124,48 @@ func (s *UserService) UpdateUser(ctx context.Context, req *connect.Request[v1.Up return nil, connect.NewError(connect.CodeInternal, errors.New("failed to update user")) } + // Update roles if provided + if len(msg.Roles) > 0 { + // Get current roles + currentRoles, _ := s.store.GetUserRoleNames(ctx, user.ID) + + // Build sets for comparison + currentSet := make(map[string]bool) + for _, r := range currentRoles { + currentSet[r] = true + } + desiredSet := make(map[string]bool) + for _, r := range msg.Roles { + desiredSet[r] = true + } + + // Remove roles not in desired set + for _, r := range currentRoles { + if !desiredSet[r] { + _ = s.store.UnassignRole(ctx, user.ID, r) + } + } + + // Add roles not in current set + for _, r := range msg.Roles { + if !currentSet[r] { + _ = s.store.AssignRole(ctx, user.ID, r, "local") + } + } + } + + roles, _ := s.store.GetUserRoleNames(ctx, user.ID) + return connect.NewResponse(&v1.UpdateUserResponse{ - User: dbUserToProto(user), + User: dbUserToProto(user, roles), }), nil } -// DeleteUser deletes a user (admin only) func (s *UserService) DeleteUser(ctx context.Context, req *connect.Request[v1.DeleteUserRequest]) (*connect.Response[v1.DeleteUserResponse], error) { - // Check admin permission - currentUser := auth.GetUserFromContext(ctx) - if currentUser == nil || currentUser.Role != storage.RoleAdmin { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("admin access required")) - } - msg := req.Msg - // Prevent self-deletion - if currentUser.ID == msg.Id { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("cannot delete your own account")) + if msg.Id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user ID is required")) } if err := s.store.DeleteUser(ctx, msg.Id); err != nil { @@ -147,6 +174,23 @@ func (s *UserService) DeleteUser(ctx context.Context, req *connect.Request[v1.De } return connect.NewResponse(&v1.DeleteUserResponse{ - Message: "User deleted successfully", + Message: "user deleted", }), nil -} \ No newline at end of file +} + +func dbUserToProto(user *storage.User, roles []string) *v1.User { + protoUser := &v1.User{ + Id: user.ID, + Username: user.Username, + Email: user.Email, + AuthProvider: user.AuthProvider, + IsActive: user.IsActive, + Roles: roles, + CreatedAt: timestamppb.New(user.CreatedAt), + UpdatedAt: timestamppb.New(user.UpdatedAt), + } + if user.LastLogin != nil { + protoUser.LastLogin = timestamppb.New(*user.LastLogin) + } + return protoUser +} diff --git a/internal/ws/hub.go b/internal/ws/hub.go index b359915..6b3c61a 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -3,6 +3,7 @@ package ws import ( "context" "net/http" + "strings" "sync" "time" @@ -10,6 +11,7 @@ import ( "github.com/nickheyer/discopanel/internal/auth" storage "github.com/nickheyer/discopanel/internal/db" "github.com/nickheyer/discopanel/internal/docker" + "github.com/nickheyer/discopanel/internal/rbac" "github.com/nickheyer/discopanel/pkg/logger" v1 "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1" "google.golang.org/protobuf/proto" @@ -33,6 +35,7 @@ const ( type Hub struct { logStreamer *logger.LogStreamer authManager *auth.Manager + enforcer *rbac.Enforcer store *storage.Store docker *docker.Client log *logger.Logger @@ -55,7 +58,7 @@ type Client struct { send chan []byte // Authentication - user *storage.User + user *auth.AuthenticatedUser authenticated bool // Subscriptions: serverId -> log channel @@ -64,10 +67,11 @@ type Client struct { } // NewHub creates a new WebSocket hub -func NewHub(logStreamer *logger.LogStreamer, authManager *auth.Manager, store *storage.Store, docker *docker.Client, log *logger.Logger) *Hub { +func NewHub(logStreamer *logger.LogStreamer, authManager *auth.Manager, enforcer *rbac.Enforcer, store *storage.Store, docker *docker.Client, log *logger.Logger) *Hub { return &Hub{ logStreamer: logStreamer, authManager: authManager, + enforcer: enforcer, store: store, docker: docker, log: log, @@ -216,16 +220,50 @@ func (c *Client) handleAuth(msg *v1.AuthMessage) { return } - ctx := context.Background() - user, err := c.hub.authManager.ValidateSession(ctx, msg.Token) - if err != nil { - c.sendAuthFail("invalid token") + // If no auth providers are enabled, bypass auth entirely - grant full admin access + if !c.hub.authManager.IsAnyAuthEnabled() { + c.user = &auth.AuthenticatedUser{ + ID: "admin", + Username: "admin", + Roles: []string{"admin"}, + Provider: "none", + } + c.authenticated = true + c.sendAuthOk() return } - c.user = user - c.authenticated = true - c.sendAuthOk() + ctx := context.Background() + + if msg.Token != "" { + var user *auth.AuthenticatedUser + var err error + if strings.HasPrefix(msg.Token, "dp_") { + user, err = c.hub.authManager.ValidateAPIToken(ctx, msg.Token) + } else { + user, err = c.hub.authManager.ValidateSession(ctx, msg.Token) + } + if err != nil { + // Try anonymous access + if c.hub.authManager.IsAnonymousAccessEnabled() { + c.user = c.hub.authManager.AnonymousUser() + c.authenticated = true + c.sendAuthOk() + return + } + c.sendAuthFail("invalid token") + return + } + c.user = user + c.authenticated = true + c.sendAuthOk() + } else if c.hub.authManager.IsAnonymousAccessEnabled() { + c.user = c.hub.authManager.AnonymousUser() + c.authenticated = true + c.sendAuthOk() + } else { + c.sendAuthFail("authentication required") + } } // handleSubscribe subscribes to server logs @@ -240,6 +278,15 @@ func (c *Client) handleSubscribe(msg *v1.SubscribeMessage) { return } + // Check permission + if c.hub.enforcer != nil && c.user != nil { + allowed, err := c.hub.enforcer.Enforce(c.user.Roles, rbac.ResourceServers, rbac.ActionRead, msg.ServerId) + if err != nil || !allowed { + c.sendError("permission denied") + return + } + } + // Get server to find container ID ctx := context.Background() server, err := c.hub.store.GetServer(ctx, msg.ServerId) @@ -248,8 +295,17 @@ func (c *Client) handleSubscribe(msg *v1.SubscribeMessage) { return } + tail := int(msg.Tail) + if tail <= 0 { + tail = 500 + } + + // If server has no container yet (just created, never started), + // send empty logs and confirm subscription without starting streaming. + // The client will re-subscribe when the server status changes. if server.ContainerID == "" { - c.sendError("server has no container") + c.sendLogs(msg.ServerId, nil) + c.sendSubscribed(msg.ServerId) return } @@ -268,11 +324,7 @@ func (c *Client) handleSubscribe(msg *v1.SubscribeMessage) { } c.subscriptionsMu.Unlock() - // Always send initial logs - tail := int(msg.Tail) - if tail <= 0 { - tail = 500 - } + // Send initial logs logs := c.hub.logStreamer.GetLogs(server.ContainerID, tail) c.sendLogs(msg.ServerId, logs) @@ -325,6 +377,15 @@ func (c *Client) handleCommand(msg *v1.CommandMessage) { return } + // Check command permission + if c.hub.enforcer != nil && c.user != nil { + allowed, err := c.hub.enforcer.Enforce(c.user.Roles, rbac.ResourceServers, rbac.ActionCommand, msg.ServerId) + if err != nil || !allowed { + c.sendCommandResult(msg.ServerId, false, "", "permission denied") + return + } + } + ctx := context.Background() server, err := c.hub.store.GetServer(ctx, msg.ServerId) if err != nil { @@ -396,12 +457,18 @@ func (c *Client) sendMessage(msg *v1.WebSocketServerMessage) { } func (c *Client) sendAuthOk() { + userId := "" + username := "" + if c.user != nil { + userId = c.user.ID + username = c.user.Username + } c.sendMessage(&v1.WebSocketServerMessage{ Type: v1.WSMessageType_WS_MESSAGE_TYPE_AUTH_OK, Payload: &v1.WebSocketServerMessage_AuthOk{ AuthOk: &v1.AuthOkMessage{ - UserId: c.user.ID, - Username: c.user.Username, + UserId: userId, + Username: username, }, }, }) diff --git a/oidc/authelia/config/configuration.yml b/oidc/authelia/config/configuration.yml new file mode 100644 index 0000000..4fe33ee --- /dev/null +++ b/oidc/authelia/config/configuration.yml @@ -0,0 +1,176 @@ +--- +# DiscoPanel - Authelia Configuration +# +# This config is ready to use for development/testing. +# For production, change ALL secrets and generate new keys. + +server: + address: 'tcp://:9091' + tls: + certificate: '/config/tls.crt' + key: '/config/tls.key' + +log: + level: 'info' + +totp: + issuer: 'discopanel' + +identity_validation: + reset_password: + jwt_secret: 'discopanel-dev-jwt-reset-secret-change-me-in-production' + +# User accounts are stored in ./users_database.yml +authentication_backend: + file: + path: '/config/users_database.yml' + password: + algorithm: 'argon2' + argon2: + variant: 'argon2id' + iterations: 3 + memory: 65536 + parallelism: 4 + key_length: 32 + salt_length: 16 + +# Authelia needs a cookie domain for its login portal. ../docker-compose.yaml for instruction +session: + secret: 'discopanel-dev-session-secret-change-me-in-production' + cookies: + - name: 'authelia_session' + domain: 'traefik.me' + authelia_url: 'https://authelia.traefik.me:9091' + expiration: '1 hour' + inactivity: '5 minutes' + +storage: + encryption_key: 'discopanel-dev-storage-encryption-key-change-me' + local: + path: '/config/db.sqlite3' + +notifier: + filesystem: + filename: '/config/notification.txt' + +access_control: + default_policy: 'one_factor' + +regulation: + max_retries: 5 + find_time: '2 minutes' + ban_time: '5 minutes' + +identity_providers: + oidc: + hmac_secret: 'discopanel-dev-hmac-secret-must-be-at-least-sixty-four-characters-long-so-here-is-padding' + enable_client_debug_messages: true + minimum_parameter_entropy: 8 + enforce_pkce: 'public_clients_only' + + jwks: + - key_id: 'discopanel-dev' + algorithm: 'RS256' + use: 'sig' + key: | + -----BEGIN PRIVATE KEY----- + MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCwvYB+KRmfkaiV + KN/wRNdPekLl4XXu8ATxhxerg2KOE31lUcrlyezH/OLEOmd0VToPnMXBUKgIMDWP + bMdsAkeXrwAdzYOZM+Ls6bFy9lr70DD4SU4yc//6vNQfqBbQ5oR3IAURS6u9Atu9 + XF+84RU/d46Sc3BhT7Z7U3VFDEt3MVLpFNLg6/YhRyguCQD24ZCE5orIwtmkSGpk + 8dxcM2Bpj9y9wdkFLEeCWSI2owpCGEENldV09oPK+a8Xqz59XBvJki4Ax3BxOAhu + EKh5qe7d40ltQn3l1a8GM6gmKNwtKK4jNo/Gl5P0+4/wwoFdxT55w1GHijHW1OnT + rDwYEb15AgMBAAECggEAAlISqFVo0TgL4x18xz5YJ2J/E16g+kirf/JapLVea2gl + Gtn2lIrQsZWH8rSjnBrsXr0buZySAD2FzoLKoYfsIbk6Aqoqoq3UOnEdE9nZOvoy + Umg//xiX0VZ+YIYH+qk0Lw48EsyQDjTF5tgaJ7Q637D1rcWXQafWyQrA/O2a5g85 + lGY4ExRXOsei+6bB1ygaYUQ1tEDyjV3VhkRNV6pfnSQljBvxVC1Xynod2mzFdzjk + AY6VRoYZ9Y5pw+V2vjiX+aI6gfwHgh4K5ogsEMUEWaPautxeKiPku+DXJ1Tb3xh0 + RVDb8VoNxxlVUf21j/0ZUwgrFX0sYLXG4r8sj3H2wwKBgQD4yrajiSkpAk71o+t8 + W2zfXYDUzjxojeCb7JraK4OzdkqGs+zSJERXcm4Q3scffBipiQ/s0sYErGCCKWo+ + Z+FU9mU0edJf+QImk3tDXKkDVXaReK69tq/T7ug6vD0lKOFCDY9HF7D/+Jh6jpiH + TX1dnYgN82UYPSO05fqsd1QgwwKBgQC13GHpV1cDhyKDX8xzhsvZWvyznAX6lqmW + NlicwHbFCyRn9g0f1UCaSV0PsdDxK+91eCZ16jiu0lPDE6vDCoVHk00QEuux+B74 + JO/0hr2mKxjcof3Na6wRAbQjbvl9BHrH7qRhdjpJRH+E2ZoectrAPrToWQOrtVBX + bn5yvBqFEwKBgCoWqSUrXBI6+L6nl3v3P4jeGaBmr2OEtP3L3jqQZ/xhQ6RcJfE6 + /3DHxAUImykhZk6wCEipM6SwwLbkaLvb+QvVjzN8dHGV/54lDxJLR7BvsdpUT0N6 + 923kGddt5u41Zz40awu8302+cZUyMG2bV10R/GVXyr96AGNnEKxCl7HfAoGAC81C + eV8WoX76iWYFIZYk0nUqIwnEBZATb1EVjQ6cZosjkK+SCHfRWnHaXTNf6Na+EnR6 + onpRtV6m2ukC44RiQ9PWU2225/S/JcFX5Rl9YzQ2x9KnYtZS80OWChqgjDFnOmRN + PJnsjGaqk9d/PeycL4+iM9Xa/CCnFxVvlUiJvAsCgYBbZPSgQtHlODNluqpr6i68 + yKfDO2hrrvSA1Y8WteM52Gpmn5b1QlopZ0/4gYmWM4qMqQmtH3uWC7NDvQmqMeFW + VPt3B7EmnT9ugKE6L9sgHtY54fIrSr8DDLl6WNLWTey4ivfhlU6o/SxMjNb8VXL3 + v0jKQTZz1R2wLqdxs1KRbg== + -----END PRIVATE KEY----- + certificate_chain: | + -----BEGIN CERTIFICATE----- + MIIDMTCCAhmgAwIBAgIUCQjLyj/HNDk2e4KdiOgmBH5dtOswDQYJKoZIhvcNAQEL + BQAwKDERMA8GA1UEAwwIYXV0aGVsaWExEzARBgNVBAoMCkRpc2NvUGFuZWwwHhcN + MjYwMjE2MjIxMjE1WhcNMzYwMjE0MjIxMjE1WjAoMREwDwYDVQQDDAhhdXRoZWxp + YTETMBEGA1UECgwKRGlzY29QYW5lbDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC + AQoCggEBALC9gH4pGZ+RqJUo3/BE1096QuXhde7wBPGHF6uDYo4TfWVRyuXJ7Mf8 + 4sQ6Z3RVOg+cxcFQqAgwNY9sx2wCR5evAB3Ng5kz4uzpsXL2WvvQMPhJTjJz//q8 + 1B+oFtDmhHcgBRFLq70C271cX7zhFT93jpJzcGFPtntTdUUMS3cxUukU0uDr9iFH + KC4JAPbhkITmisjC2aRIamTx3FwzYGmP3L3B2QUsR4JZIjajCkIYQQ2V1XT2g8r5 + rxerPn1cG8mSLgDHcHE4CG4QqHmp7t3jSW1CfeXVrwYzqCYo3C0oriM2j8aXk/T7 + j/DCgV3FPnnDUYeKMdbU6dOsPBgRvXkCAwEAAaNTMFEwHQYDVR0OBBYEFNMzG9im + j+WQsYBQnEk5SbXRzhozMB8GA1UdIwQYMBaAFNMzG9imj+WQsYBQnEk5SbXRzhoz + MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHy1bE1X/J4jiTSd + dzzV+7imfRKWjXA3l1R7ztfXil5KgQxoG9KyCGIsS5yRD94BH6k9n15wxFwa3xbw + aPzr6jAt+I/S4WLoAtWZkRDdnOMpCslydStvBJso9Sf3yP0omcfRab7B49JYg+4g + SaHFt0W5ncgaHNJat2uwtNa3XeMo3uyatZw99y16X2XlOzkhPWl0PHb2Wet6JGbW + rwfjJuT9gmr5LpIHXXIpoz8COQ/tWqDwcwxOh7Vhz4h8ohMaBF7DwtwEUU0HFe1L + KhlYOOn3iEYMCHoRCwqfuCty3kXbUigqOdC+3GD3sPJHsetZpju6mzgRUK6HwzB7 + ry2+WVw= + -----END CERTIFICATE----- + + lifespans: + access_token: '1h' + authorize_code: '1m' + id_token: '1h' + refresh_token: '90m' + + # This policy puts "groups" directly in the ID token. Without it, groups only show up in the UserInfo endpoint, and Discopanel wouldn't read roles. + claims_policies: + discopanel: + id_token: + - 'groups' + - 'email' + - 'email_verified' + - 'preferred_username' + - 'name' + + cors: + endpoints: + - 'authorization' + - 'token' + - 'revocation' + - 'introspection' + - 'userinfo' + allowed_origins_from_client_redirect_uris: true + + clients: + - client_id: 'discopanel' + client_name: 'DiscoPanel' + # Plaintext secret: discopanel-dev-secret + client_secret: '$pbkdf2-sha512$310000$rjIc/fKRSBnSAe/FKJa.aQ$KIJjfQsCOexmk52nLb73HmJrcMkiDl3GRuWWpmdJN.talWSB.p7cq7zbiiVj4P0xV3YMeJtdlMfzWSVU2XSblw' + public: false + authorization_policy: 'one_factor' + claims_policy: 'discopanel' + redirect_uris: + - 'http://localhost:8080/api/v1/auth/oidc/callback' + - 'http://localhost:5173/api/v1/auth/oidc/callback' + scopes: + - 'openid' + - 'profile' + - 'email' + - 'groups' + - 'offline_access' + grant_types: + - 'authorization_code' + - 'refresh_token' + response_types: + - 'code' + response_modes: + - 'query' + - 'form_post' + token_endpoint_auth_method: 'client_secret_basic' diff --git a/oidc/authelia/config/tls.crt b/oidc/authelia/config/tls.crt new file mode 100644 index 0000000..16175ab --- /dev/null +++ b/oidc/authelia/config/tls.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTDCCAjSgAwIBAgIUCw6dx3TcreCmZh2d9Dd3I4DgsPUwDQYJKoZIhvcNAQEL +BQAwHjEcMBoGA1UEAwwTYXV0aGVsaWEudHJhZWZpay5tZTAeFw0yNjAyMTcwMDMy +NDdaFw0zNjAyMTUwMDMyNDdaMB4xHDAaBgNVBAMME2F1dGhlbGlhLnRyYWVmaWsu +bWUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDRRBUQzQeTbP3dHpwG +E10k7+Os+bv8gClOilNzZul2Pbe1peCWHaHx6lsmB+6DNcfRKBBzBVps+9T74pdl +huFiG6Q6lLL8mUfCUlaJYm12zZRN0q/8O9hM+k/lPp4XPIsm+n4jslIVNWjfbmla +v7PgftoxKAY9h2SI3dpdMOKVlUYevEdvwQo1f0Ag+hLzfK9XDK7q6M4rbN+oV/yy +BktuYz6jWGqF0k5Ao8gIz2NX2VBco/nmP8vwMt99/5ZhMkgeZzmcObBMf2Je4fs2 +eQEVVOhNua1EoRYkImN6mLzRmXl10fHYK4rzBzMo6gLMhpNtJ+6VhmyeYvkdfzZO +r9DlAgMBAAGjgYEwfzAdBgNVHQ4EFgQUEfZyRhx1c6O21Etj6pnh73lP9SowHwYD +VR0jBBgwFoAUEfZyRhx1c6O21Etj6pnh73lP9SowDwYDVR0TAQH/BAUwAwEB/zAs +BgNVHREEJTAjghNhdXRoZWxpYS50cmFlZmlrLm1lggwqLnRyYWVmaWsubWUwDQYJ +KoZIhvcNAQELBQADggEBAE82joNb/45do0s9rf9B2CIacH4g98umweyKVud5IcnD +jfHHTBxL0Hhs6Emvg7UfARbAnt66PyJYk9KUp+FzrWYkjVuXERAUesBfw5tTjUMG +MvRmRc9ro19S4IG4vn23y5vOaElNdMFM3dFci5WVYKJbc6Knswpb3QhzQ6p6zwzP +/hWeWkm1glxQ0o8fAyF0DpHpQcQzNOxw6wNK1DPr14rJYT4DqwaT37yM8zxOoibn +9PerOh3AZgKFvdZoaWSSWp9PRLGQ3VPM9E72+ExWF3eXT5SktE2PoUJdt7zISGLe +g3q1V7Q8Y8H/0JtOqOqULw3XFlbGHAxukZ+W9WccHe4= +-----END CERTIFICATE----- diff --git a/oidc/authelia/config/tls.key b/oidc/authelia/config/tls.key new file mode 100644 index 0000000..c8bfd83 --- /dev/null +++ b/oidc/authelia/config/tls.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRRBUQzQeTbP3d +HpwGE10k7+Os+bv8gClOilNzZul2Pbe1peCWHaHx6lsmB+6DNcfRKBBzBVps+9T7 +4pdlhuFiG6Q6lLL8mUfCUlaJYm12zZRN0q/8O9hM+k/lPp4XPIsm+n4jslIVNWjf +bmlav7PgftoxKAY9h2SI3dpdMOKVlUYevEdvwQo1f0Ag+hLzfK9XDK7q6M4rbN+o +V/yyBktuYz6jWGqF0k5Ao8gIz2NX2VBco/nmP8vwMt99/5ZhMkgeZzmcObBMf2Je +4fs2eQEVVOhNua1EoRYkImN6mLzRmXl10fHYK4rzBzMo6gLMhpNtJ+6VhmyeYvkd +fzZOr9DlAgMBAAECggEAHxbuzEKxq/Dm3FmGU46/6VNsb0/g4lgGCwGY+U2iRKtR +pj6BGbxISYEITqOiB0NPrt61Zuk2MHfPgiZ9WJuL04AIy5045Dc/hnqmGZ4SZjKP +pGo3NBGOBo2vnf7KDOi1QbK4V8RP1o/LR1qHc3CEoEcoUmJAXxbE1GKlZO/00cUJ +R6n07R2JVDKlZzzGrRb5eISRMAFwn5W9dzCtdrlaLKRSfSEl+E/3QJE6g2EbLByA +wwTGtMoZvTjhWNhCaSqhfFyxRlbf4bBA6WN+mJrgxbF0iaKNQ5UawUa+Lej77aRy +kn1zgttEl2Mnoqk7mbou1ZgRnlizDWvTm56ir8iQUQKBgQDvpAxllJKmsrk/PNiI +cWKc11EkQIfs2rbB0LQQTJqRChKz0r/oH4egXtjZLlcrhsmhk9ZNV3QBoz0NliE3 +WCxCAiGNu/MsmdUFyteI+xuxTwbF9xCZxLiDg2bCeBu5y9pqfiN62egzToLligd7 +c4Kk0gJ5Dpb0spzMRhK9thtdDQKBgQDfjTRARZN5EJWb4JBJ5as0/s4FJhHT0DIZ +WR2ThBHmv3wr9vrKD8pAEbY9C57yLKXODyK9EWXF+ukNjudy3Kqc5mFeBa+qsEGR +qOS6xWplQQVc2c6Bs9O9b4oD/CkdnUMOT9PaaVHvrUCwzU7tVq5VjFvyAz1PnGY4 +ARQKUBw9OQKBgQCOPK3LAUOGRCCmA0R2v+4LL9YOkWrcT/kX0vt9jSpVGkh9iYK0 +kTpcGs/VIKdGw4scJ3aUk2rcqfpL/SccBW7HgyJNURiGCYyiEoKZ4InQVRqtF/c3 +fccS8ERm+wlh3zh16wa+HWawRVJ2UdYdFTOfBrPHDLzW4skkihcHmXZmZQKBgBTx +qb+LxTFGeH3OIDaMKeohJTQeSPVLQCZXzwmPCg5QSlXkIcLkj9JI1oYJnK6buD0B +9gM4qgxOYZ8/kDeWrPVeMCka50ZalQoMhMFq1Xj/Cn2UemB0dJX+6TNOYJvBrBKf +L/36eA64cKMf2RErWdHyAHtACnJ2+KyujS4aK0shAoGBAMTjIP+Adx3z0HzSVgHj +NEBpbgZ+cGcSUp25oaLffj2O5OCSARnuvbI8mso3/x820Jn8igie1oLPHGJhSBjq +8gbzdMSuFZdz9ZT+9HOxdJT2IueiC5DncK/5a+d1Qr/u5+vGyzWOvP0qMO+iJns2 +V9p/Ek7ccIa0u9eTawNnraHJ +-----END PRIVATE KEY----- diff --git a/oidc/authelia/config/users_database.yml b/oidc/authelia/config/users_database.yml new file mode 100644 index 0000000..fd27bff --- /dev/null +++ b/oidc/authelia/config/users_database.yml @@ -0,0 +1,21 @@ +--- +# DiscoPanel - Authelia Users +# +# Do NOT use default admin in production. +# Default login is "admin" / "admin" +# +# To generate a new password hash: +# +# docker run --rm authelia/authelia:latest \ +# authelia crypto hash generate argon2 \ +# --password 'your-password-here' + +users: + admin: # username + disabled: false + displayname: 'Discopanel Admin' + password: '$argon2id$v=19$m=65536,t=3,p=4$9X6yDiM/+/Wi1dBCzPGftw$7hqUauLP/Hh9Z5KlLQn/2IVNdX+/vbWLFgz/i+TtchI' # password (hash of "admin") + email: 'admin@discopanel.local' + groups: + - 'admin' + diff --git a/oidc/authelia/docker-compose.yaml b/oidc/authelia/docker-compose.yaml new file mode 100644 index 0000000..fa893c2 --- /dev/null +++ b/oidc/authelia/docker-compose.yaml @@ -0,0 +1,67 @@ +# DiscoPanel + Authelia (OIDC) +# +# This is a complete docker-compose with OIDC authentication pre-configured using Authelia. +# +# NOTE #1: Default users should be changed (SEE oidc/authelia/config/users_database.yml). +# Passwords are stored as hash, see instructions. Use same hash cmd for secret below (when setting in oidc/authelia/config/configuration.yaml) if needed. +# +# NOTE #2: Authelia is generally intended to be used with a proxy like traefik. Plenty of guides online for that. +# Feel free to throw away the example configuration.yml and users_database.yml !!!! Just make sure groups are included in claims. + +services: + discopanel: + image: nickheyer/discopanel:dev + container_name: discopanel + restart: unless-stopped + network_mode: host + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /tmp/discopanel:/app/data + environment: + - DISCOPANEL_DATA_DIR=/app/data + - DISCOPANEL_HOST_DATA_PATH=/tmp/discopanel + - TZ=UTC + + # ------------------------------------ AUTH CONFIG STARTS HERE FOR DISCOPANEL + AUTHELIA ------------------------------------ + - DISCOPANEL_AUTH_LOCAL_ENABLED=true + - DISCOPANEL_AUTH_OIDC_ENABLED=true + + # MUST MATCH oidc/authelia/config/configuration.yaml + - DISCOPANEL_AUTH_OIDC_ISSUER_URI=https://authelia.traefik.me:9091 + + # ONLY CHANGE THIS IF YOUR CLIENT NAME IS DIFFERENT + - DISCOPANEL_AUTH_OIDC_CLIENT_ID=discopanel + + # YOU SHOULD CHANGE THIS HERE AS WELL AS THE HASHED ONE IN oidc/authelia/config/configuration.yaml (inside the identity_providers.client) + - DISCOPANEL_AUTH_OIDC_CLIENT_SECRET=discopanel-dev-secret + + # YOU SHOULD CHANGE "localhost:8080" TO WHATEVER YOUR PUBLIC DOMAIN IS FOR DISCOPANEL (ie: https://mypanel.com/api/v1/auth/oidc/callback) + - DISCOPANEL_AUTH_OIDC_REDIRECT_URL=http://localhost:8080/api/v1/auth/oidc/callback + + - DISCOPANEL_AUTH_OIDC_ROLE_CLAIM=groups + + # SKIPPING TLS VERIFY HERE BECAUSE TLS CERTS ARE SELF SIGNED. IF USING YOUR OWN CERTS (in authelia mounts), REMOVE THIS. + - DISCOPANEL_AUTH_OIDC_SKIP_TLS_VERIFY=true + + depends_on: + authelia: + condition: service_healthy + + authelia: + image: authelia/authelia:latest + container_name: authelia + volumes: + - ./config/configuration.yml:/config/configuration.yml:ro + - ./config/users_database.yml:/config/users_database.yml:ro + - ./config/tls.crt:/config/tls.crt:ro # THE INCLUDED CERT IS SELF SIGNED + - ./config/tls.key:/config/tls.key:ro + ports: + - "9091:9091" + healthcheck: + test: ["CMD", "wget", "--no-check-certificate", "--spider", "https://localhost:9091/api/health"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + environment: + - TZ=UTC diff --git a/oidc/keycloak/config/realm.json b/oidc/keycloak/config/realm.json new file mode 100644 index 0000000..2f14ae2 --- /dev/null +++ b/oidc/keycloak/config/realm.json @@ -0,0 +1,106 @@ +{ + "realm": "discopanel", + "enabled": true, + "registrationAllowed": false, + "resetPasswordAllowed": true, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "sslRequired": "none", + "roles": { + "realm": [ + { + "name": "admin", + "description": "DiscoPanel administrator" + }, + { + "name": "user", + "description": "DiscoPanel user" + } + ] + }, + "groups": [ + { + "name": "admin", + "realmRoles": ["admin"] + }, + { + "name": "user", + "realmRoles": ["user"] + } + ], + "defaultGroups": ["user"], + "clients": [ + { + "clientId": "discopanel", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "secret": "discopanel-dev-secret", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:8080/*", + "http://localhost:5173/*" + ], + "webOrigins": [ + "http://localhost:8080", + "http://localhost:5173" + ], + "defaultClientScopes": [ + "openid", + "profile", + "email", + "roles" + ], + "protocolMappers": [ + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "config": { + "full.path": "false", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "name": "realm-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + } + ], + "users": [ + { + "username": "admin", + "enabled": true, + "email": "admin@discopanel.local", + "emailVerified": true, + "firstName": "Disco", + "lastName": "Admin", + "credentials": [ + { + "type": "password", + "value": "admin", + "temporary": false + } + ], + "groups": ["admin", "user"] + } + ] +} diff --git a/oidc/keycloak/docker-compose.yaml b/oidc/keycloak/docker-compose.yaml new file mode 100644 index 0000000..b43dde9 --- /dev/null +++ b/oidc/keycloak/docker-compose.yaml @@ -0,0 +1,100 @@ +# DiscoPanel + Keycloak (OIDC) +# +# This is a complete docker-compose with OIDC authentication pre-configured using Keycloak. +# +# Keycloak takes ~30-60 seconds to start. DiscoPanel waits for it. +# +# Keycloak Admin UI (See KC_BOOTSTRAP_ADMIN_USERNAME/KC_BOOTSTRAP_ADMIN_PASSWORD below): http://localhost:8180/admin + +services: + discopanel: + build: + context: ../../ + dockerfile: docker/Dockerfile.discopanel + #image: nickheyer/discopanel:dev + container_name: discopanel + restart: unless-stopped + network_mode: host + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /tmp/discopanel:/app/data + environment: + - DISCOPANEL_DATA_DIR=/app/data + - DISCOPANEL_HOST_DATA_PATH=/tmp/discopanel + - TZ=UTC + + # ------------------------------------ AUTH CONFIG STARTS HERE FOR DISCOPANEL + KEYCLOAK ------------------------------------ + - DISCOPANEL_AUTH_LOCAL_ENABLED=true + - DISCOPANEL_AUTH_OIDC_ENABLED=true + + # ONLY CHANGE THIS IF YOU ARE HOSTING OIDC ON ANOTHER MACHINE OR WITH A DIFFERENT "realm" NAME + - DISCOPANEL_AUTH_OIDC_ISSUER_URI=http://localhost:8180/realms/discopanel + + # ONLY CHANGE THIS IF YOUR CLIENT NAME IS DIFFERENT + - DISCOPANEL_AUTH_OIDC_CLIENT_ID=discopanel + + # YOU SHOULD CHANGE THIS HERE AS WELL AS IN oidc/keycloak/config/realm.json (inside the clients object) + - DISCOPANEL_AUTH_OIDC_CLIENT_SECRET=discopanel-dev-secret + + # YOU SHOULD CHANGE "localhost:8080" TO WHATEVER YOUR PUBLIC DOMAIN IS FOR DISCOPANEL (ie: https://mypanel.com/api/v1/auth/oidc/callback) + - DISCOPANEL_AUTH_OIDC_REDIRECT_URL=http://localhost:8080/api/v1/auth/oidc/callback + + - DISCOPANEL_AUTH_OIDC_ROLE_CLAIM=groups + + depends_on: + keycloak: + condition: service_healthy + + keycloak: + image: quay.io/keycloak/keycloak:26.1 + container_name: keycloak + command: start-dev --import-realm + volumes: + # THIS IS AN EXAMPLE REALM CONFIG, MODIFY AS NEEDED (WHICH MAY REQUIRE MODIFYING DISCOPANEL CONFIG TO MATCH, SEE ABOVE) + - ./config/realm.json:/opt/keycloak/data/import/realm.json:ro + environment: + # KEYCLOAK ADMIN LOGIN CREDENTIALS - CHANGE THESE!!!!! + - KC_BOOTSTRAP_ADMIN_USERNAME=admin + - KC_BOOTSTRAP_ADMIN_PASSWORD=admin + + # KEYCLOAK DATABASE CONFIG, DONT CHANGE THESE UNLESS YOU KNOW WHAT YOU ARE DOING OR USING EXISTING DATABASE + - KC_DB=postgres + - KC_DB_URL_HOST=keycloak-db + - KC_DB_URL_DATABASE=keycloak + - KC_DB_USERNAME=keycloak + - KC_DB_PASSWORD=keycloak + + # MISC KEYCLOAK CONFIGS + - KC_HOSTNAME_STRICT=false + - KC_HTTP_ENABLED=true + - KC_HEALTH_ENABLED=true + - KC_PROXY_HEADERS=xforwarded + ports: + - "8180:8080" + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3; timeout 2 cat <&3 | grep -q '200 OK'"] + interval: 20s + timeout: 5s + retries: 12 + start_period: 30s + depends_on: + keycloak-db: + condition: service_healthy + + keycloak-db: + image: postgres:17-alpine + container_name: keycloak-db + environment: + - POSTGRES_DB=keycloak + - POSTGRES_USER=keycloak + - POSTGRES_PASSWORD=keycloak + volumes: + - keycloak-db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keycloak"] + interval: 10s + timeout: 3s + retries: 5 + +volumes: + keycloak-db-data: diff --git a/pkg/utils/strings.go b/pkg/utils/strings.go new file mode 100644 index 0000000..830f32d --- /dev/null +++ b/pkg/utils/strings.go @@ -0,0 +1,13 @@ +package utils + +func DeduplicateStrings(strings []string) []string { + seen := make(map[string]bool) + result := []string{} + for _, s := range strings { + if !seen[s] { + seen[s] = true + result = append(result, s) + } + } + return result +} diff --git a/proto/discopanel/v1/auth.proto b/proto/discopanel/v1/auth.proto index d836cb5..7771f67 100644 --- a/proto/discopanel/v1/auth.proto +++ b/proto/discopanel/v1/auth.proto @@ -3,30 +3,83 @@ syntax = "proto3"; package discopanel.v1; import "discopanel/v1/common.proto"; +import "gnostic/openapi/v3/annotations.proto"; import "google/protobuf/timestamp.proto"; option go_package = "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1;discopanelv1"; +// Global OpenAPI security: Bearer token auth via session JWT or API token (dp_...) +option (gnostic.openapi.v3.document) = { + info: { + title: "DiscoPanel API" + version: "2.x" + description: "DiscoPanel server management API. Authenticate using a session token (from Login/OIDC) or an API token (dp_... prefix) in the Authorization header.\n\n## Connect Protocol\n\nThis API uses the [Connect protocol](https://connectrpc.com/docs/protocol) over HTTP. All endpoints accept `POST` with `application/json` bodies. Connect clients may send the following optional headers:\n\n- `Connect-Protocol-Version: 1` — declares the Connect protocol version.\n- `Connect-Timeout-Ms: ` — request deadline in milliseconds.\n\nThese headers are handled automatically by Connect client libraries and are not required when calling the API directly with curl or other HTTP clients." + } + components: { + security_schemes: { + additional_properties: [ + { + name: "BearerAuth" + value: { + security_scheme: { + type: "http" + scheme: "bearer" + description: "Session JWT from login, or API token with dp_ prefix" + } + } + } + ] + } + } + security: [ + { + additional_properties: [ + { + name: "BearerAuth" + value: { + value: [] + } + } + ] + } + ] +}; -// Authentication and authorization service +// Authentication service service AuthService { - // Check if auth is enabled + // Check auth system status (public) rpc GetAuthStatus(GetAuthStatusRequest) returns (GetAuthStatusResponse); - // Authenticate user credentials + // Authenticate with local credentials (public) rpc Login(LoginRequest) returns (LoginResponse); - // Invalidate session token + // Invalidate session rpc Logout(LogoutRequest) returns (LogoutResponse); - // Create new user account + // Register new local account (public) rpc Register(RegisterRequest) returns (RegisterResponse); - // Reset password with recovery key - rpc ResetPassword(ResetPasswordRequest) returns (ResetPasswordResponse); - // Get auth configuration - rpc GetAuthConfig(GetAuthConfigRequest) returns (GetAuthConfigResponse); - // Modify auth configuration - rpc UpdateAuthConfig(UpdateAuthConfigRequest) returns (UpdateAuthConfigResponse); // Get authenticated user info rpc GetCurrentUser(GetCurrentUserRequest) returns (GetCurrentUserResponse); - // Change user's own password + // Change own password (local auth only) rpc ChangePassword(ChangePasswordRequest) returns (ChangePasswordResponse); + // Get OIDC login redirect URL (public) + rpc GetOIDCLoginURL(GetOIDCLoginURLRequest) returns (GetOIDCLoginURLResponse); + // Get full auth configuration + rpc GetAuthConfig(GetAuthConfigRequest) returns (GetAuthConfigResponse); + // Update mutable auth settings + rpc UpdateAuthSettings(UpdateAuthSettingsRequest) returns (UpdateAuthSettingsResponse); + // Create a registration invite link + rpc CreateInvite(CreateInviteRequest) returns (CreateInviteResponse); + // List all registration invites + rpc ListInvites(ListInvitesRequest) returns (ListInvitesResponse); + // Get a specific invite by ID + rpc GetInvite(GetInviteRequest) returns (GetInviteResponse); + // Delete/revoke an invite + rpc DeleteInvite(DeleteInviteRequest) returns (DeleteInviteResponse); + // Validate an invite code (public) + rpc ValidateInvite(ValidateInviteRequest) returns (ValidateInviteResponse); + // Create a new API token for the authenticated user + rpc CreateAPIToken(CreateAPITokenRequest) returns (CreateAPITokenResponse); + // List all API tokens for the authenticated user + rpc ListAPITokens(ListAPITokensRequest) returns (ListAPITokensResponse); + // Delete/revoke an API token + rpc DeleteAPIToken(DeleteAPITokenRequest) returns (DeleteAPITokenResponse); } // Empty auth status request @@ -34,12 +87,14 @@ message GetAuthStatusRequest {} // Auth system state message GetAuthStatusResponse { - bool enabled = 1; - bool first_user_setup = 2; + bool local_auth_enabled = 1; + bool oidc_enabled = 2; bool allow_registration = 3; + bool first_user_setup = 4; + bool anonymous_access_enabled = 5; } -// User credentials +// Local login credentials message LoginRequest { string username = 1; string password = 2; @@ -60,70 +115,179 @@ message LogoutResponse { string message = 1; } -// New account info +// New local account message RegisterRequest { string username = 1; string email = 2; string password = 3; + optional string invite_code = 4; + optional string invite_pin = 5; } -// Created user account +// Created user message RegisterResponse { User user = 1; } -// Password reset credentials -message ResetPasswordRequest { - string username = 1; - string recovery_key = 2; - string new_password = 3; +// Empty current user request +message GetCurrentUserRequest {} + +// Authenticated user with permissions +message GetCurrentUserResponse { + User user = 1; + repeated Permission permissions = 2; } -// Reset confirmation -message ResetPasswordResponse { +// Password change +message ChangePasswordRequest { + string old_password = 1; + string new_password = 2; +} + +// Password change confirmation +message ChangePasswordResponse { string message = 1; } -// Empty config request +// OIDC login URL request +message GetOIDCLoginURLRequest {} + +// OIDC login redirect URL +message GetOIDCLoginURLResponse { + string login_url = 1; +} + +// Empty auth config request message GetAuthConfigRequest {} -// Current auth settings +// Full auth configuration for admin display message GetAuthConfigResponse { - bool enabled = 1; - int32 session_timeout = 2; - bool require_email_verify = 3; - bool allow_registration = 4; + bool local_auth_enabled = 1; + bool allow_registration = 2; + bool anonymous_access = 3; + int32 session_timeout = 4; + bool oidc_enabled = 5; + // OIDC display details (non-secret, only populated when OIDC is enabled) + optional string oidc_issuer_uri = 6; + optional string oidc_client_id = 7; + optional string oidc_redirect_url = 8; + repeated string oidc_scopes = 9; + optional string oidc_role_claim = 10; + bool first_user_setup = 11; } -// Auth settings to update -message UpdateAuthConfigRequest { - optional bool enabled = 1; - optional int32 session_timeout = 2; - optional bool require_email_verify = 3; - optional bool allow_registration = 4; +// Update mutable auth settings (all fields optional, only provided fields are updated) +message UpdateAuthSettingsRequest { + optional bool local_auth_enabled = 1; + optional bool allow_registration = 2; + optional bool anonymous_access = 3; + optional int32 session_timeout = 4; } -// Config update result -message UpdateAuthConfigResponse { - string message = 1; - bool requires_first_user = 2; // If enabling auth with no users +// Returns the updated auth configuration +message UpdateAuthSettingsResponse { + GetAuthConfigResponse config = 1; } -// Empty current user request -message GetCurrentUserRequest {} +// Registration invite link +message RegistrationInvite { + string id = 1; + string code = 2; + string description = 3; + repeated string roles = 4; + bool has_pin = 5; + int32 max_uses = 6; + int32 use_count = 7; + google.protobuf.Timestamp expires_at = 8; + string created_by = 9; + google.protobuf.Timestamp created_at = 10; +} -// Authenticated user info -message GetCurrentUserResponse { - User user = 1; +// New invite with roles, optional PIN, and expiry +message CreateInviteRequest { + string description = 1; + repeated string roles = 2; + optional string pin = 3; + int32 max_uses = 4; + optional int32 expires_in_hours = 5; } -// Password change credentials -message ChangePasswordRequest { - string old_password = 1; - string new_password = 2; +// Created invite details +message CreateInviteResponse { + RegistrationInvite invite = 1; } -// Password change confirmation -message ChangePasswordResponse { - string message = 1; +// Empty list invites request +message ListInvitesRequest {} + +// All registration invites +message ListInvitesResponse { + repeated RegistrationInvite invites = 1; +} + +// Invite lookup by ID +message GetInviteRequest { + string id = 1; +} + +// Single invite details +message GetInviteResponse { + RegistrationInvite invite = 1; +} + +// Invite deletion by ID +message DeleteInviteRequest { + string id = 1; +} + +// Empty delete invite confirmation +message DeleteInviteResponse {} + +// Invite code to validate +message ValidateInviteRequest { + string code = 1; +} + +// Invite validity and PIN requirement +message ValidateInviteResponse { + bool valid = 1; + bool requires_pin = 2; + string description = 3; } + +// API Token metadata (never includes the token value) +message ApiToken { + string id = 1; + string name = 2; + google.protobuf.Timestamp expires_at = 3; + google.protobuf.Timestamp last_used_at = 4; + google.protobuf.Timestamp created_at = 5; +} + +// New API token with name and optional expiry +message CreateAPITokenRequest { + string name = 1; + optional int32 expires_in_days = 2; +} + +// Plaintext token (shown once) and metadata +message CreateAPITokenResponse { + string plaintext_token = 1; + ApiToken api_token = 2; +} + +// Empty list API tokens request +message ListAPITokensRequest {} + +// All API tokens for the authenticated user +message ListAPITokensResponse { + repeated ApiToken api_tokens = 1; +} + +// API token deletion by ID +message DeleteAPITokenRequest { + string id = 1; +} + +// Empty delete API token confirmation +message DeleteAPITokenResponse {} diff --git a/proto/discopanel/v1/common.proto b/proto/discopanel/v1/common.proto index 0aa2d98..cdbe417 100644 --- a/proto/discopanel/v1/common.proto +++ b/proto/discopanel/v1/common.proto @@ -6,14 +6,6 @@ import "google/protobuf/timestamp.proto"; option go_package = "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1;discopanelv1"; -// User permission levels -enum UserRole { - USER_ROLE_UNSPECIFIED = 0; - USER_ROLE_VIEWER = 1; - USER_ROLE_EDITOR = 2; - USER_ROLE_ADMIN = 3; -} - // Container runtime state enum ServerStatus { SERVER_STATUS_UNSPECIFIED = 0; @@ -54,11 +46,12 @@ message User { string id = 1; string username = 2; optional string email = 3; - UserRole role = 4; + string auth_provider = 4; bool is_active = 5; - optional string recovery_key = 6; + repeated string roles = 6; google.protobuf.Timestamp created_at = 7; google.protobuf.Timestamp updated_at = 8; + optional google.protobuf.Timestamp last_login = 9; } // Minecraft server instance @@ -170,13 +163,21 @@ message ProxyConfig { google.protobuf.Timestamp updated_at = 5; } -// Authentication system settings -message AuthConfig { +// Permission entry for RBAC +message Permission { + string resource = 1; + string action = 2; + string object_id = 3; +} + +// Role definition +message Role { string id = 1; - bool enabled = 2; - int32 session_timeout = 3; - bool require_email_verify = 4; - bool allow_registration = 5; - google.protobuf.Timestamp created_at = 6; - google.protobuf.Timestamp updated_at = 7; + string name = 2; + string description = 3; + bool is_system = 4; + bool is_default = 5; + repeated Permission permissions = 6; + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp updated_at = 8; } diff --git a/proto/discopanel/v1/role.proto b/proto/discopanel/v1/role.proto new file mode 100644 index 0000000..b29ff3d --- /dev/null +++ b/proto/discopanel/v1/role.proto @@ -0,0 +1,159 @@ +syntax = "proto3"; + +package discopanel.v1; + +import "discopanel/v1/common.proto"; + +option go_package = "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1;discopanelv1"; + +// Role and permission management service +service RoleService { + // List all roles + rpc ListRoles(ListRolesRequest) returns (ListRolesResponse); + // Get a single role + rpc GetRole(GetRoleRequest) returns (GetRoleResponse); + // Create a new custom role + rpc CreateRole(CreateRoleRequest) returns (CreateRoleResponse); + // Update a role + rpc UpdateRole(UpdateRoleRequest) returns (UpdateRoleResponse); + // Delete a custom role + rpc DeleteRole(DeleteRoleRequest) returns (DeleteRoleResponse); + // Get permission matrix for all roles + rpc GetPermissionMatrix(GetPermissionMatrixRequest) returns (GetPermissionMatrixResponse); + // Set permissions for a role + rpc UpdatePermissions(UpdatePermissionsRequest) returns (UpdatePermissionsResponse); + // Assign a role to a user + rpc AssignRole(AssignRoleRequest) returns (AssignRoleResponse); + // Unassign a role from a user + rpc UnassignRole(UnassignRoleRequest) returns (UnassignRoleResponse); + // Get roles for a user + rpc GetUserRoles(GetUserRolesRequest) returns (GetUserRolesResponse); +} + +// Empty list request +message ListRolesRequest {} + +// All roles +message ListRolesResponse { + repeated Role roles = 1; +} + +// Get role by ID +message GetRoleRequest { + string id = 1; +} + +// Single role +message GetRoleResponse { + Role role = 1; +} + +// New role details +message CreateRoleRequest { + string name = 1; + string description = 2; + bool is_default = 3; + repeated Permission permissions = 4; +} + +// Created role +message CreateRoleResponse { + Role role = 1; +} + +// Role fields to update +message UpdateRoleRequest { + string id = 1; + optional string name = 2; + optional string description = 3; + optional bool is_default = 4; +} + +// Updated role +message UpdateRoleResponse { + Role role = 1; +} + +// Role to delete +message DeleteRoleRequest { + string id = 1; +} + +// Deletion confirmation +message DeleteRoleResponse { + string message = 1; +} + +// Permission matrix request +message GetPermissionMatrixRequest { + bool include_objects = 1; +} + +// An object that can be scoped in permissions (e.g. a specific server) +message ScopeableObject { + string id = 1; + string name = 2; + string resource = 3; + string scope_source = 4; // Entity type providing scope (e.g., "servers" when files scoped by server) +} + +// Valid actions per resource, derived from procedure mappings +message ResourceActions { + string resource = 1; + repeated string actions = 2; +} + +// Permission matrix response +message GetPermissionMatrixResponse { + repeated ResourceActions resource_actions = 1; + map role_permissions = 2; + repeated ScopeableObject available_objects = 3; +} + +// Permissions for a single role +message RolePermissions { + repeated Permission permissions = 1; +} + +// Update permissions for a role +message UpdatePermissionsRequest { + string role_name = 1; + repeated Permission permissions = 2; +} + +// Update confirmation +message UpdatePermissionsResponse { + string message = 1; +} + +// Assign role to user +message AssignRoleRequest { + string user_id = 1; + string role_name = 2; +} + +// Assignment confirmation +message AssignRoleResponse { + string message = 1; +} + +// Unassign role from user +message UnassignRoleRequest { + string user_id = 1; + string role_name = 2; +} + +// Unassignment confirmation +message UnassignRoleResponse { + string message = 1; +} + +// Get user roles request +message GetUserRolesRequest { + string user_id = 1; +} + +// User roles response +message GetUserRolesResponse { + repeated string roles = 1; +} diff --git a/proto/discopanel/v1/user.proto b/proto/discopanel/v1/user.proto index 5223a34..ce94f39 100644 --- a/proto/discopanel/v1/user.proto +++ b/proto/discopanel/v1/user.proto @@ -10,6 +10,8 @@ option go_package = "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1;dis service UserService { // Get all system users rpc ListUsers(ListUsersRequest) returns (ListUsersResponse); + // Get a single user + rpc GetUser(GetUserRequest) returns (GetUserResponse); // Create new user account rpc CreateUser(CreateUserRequest) returns (CreateUserResponse); // Modify user account @@ -26,12 +28,22 @@ message ListUsersResponse { repeated User users = 1; } +// Get user by ID +message GetUserRequest { + string id = 1; +} + +// Single user +message GetUserResponse { + User user = 1; +} + // New user details message CreateUserRequest { string username = 1; string email = 2; string password = 3; - UserRole role = 4; + repeated string roles = 4; } // Created user @@ -43,8 +55,8 @@ message CreateUserResponse { message UpdateUserRequest { string id = 1; optional string email = 2; - optional UserRole role = 3; - optional bool is_active = 4; + optional bool is_active = 3; + repeated string roles = 4; } // Updated user diff --git a/web/discopanel/src/lib/api/rpc-client.ts b/web/discopanel/src/lib/api/rpc-client.ts index 6e10042..da1f265 100644 --- a/web/discopanel/src/lib/api/rpc-client.ts +++ b/web/discopanel/src/lib/api/rpc-client.ts @@ -17,6 +17,7 @@ import { SupportService } from '$lib/proto/discopanel/v1/support_pb'; import { TaskService } from '$lib/proto/discopanel/v1/task_pb'; import { UploadService } from '$lib/proto/discopanel/v1/upload_pb'; import { UserService } from '$lib/proto/discopanel/v1/user_pb'; +import { RoleService } from '$lib/proto/discopanel/v1/role_pb'; import { ModuleService } from '$lib/proto/discopanel/v1/module_pb'; // Header to mark requests as silent / no loader @@ -81,6 +82,7 @@ export class RpcClient { public readonly task: Client; public readonly upload: Client; public readonly user: Client; + public readonly role: Client; public readonly module: Client; constructor() { @@ -96,6 +98,7 @@ export class RpcClient { this.task = createClient(TaskService, transport); this.upload = createClient(UploadService, transport); this.user = createClient(UserService, transport); + this.role = createClient(RoleService, transport); this.module = createClient(ModuleService, transport); } } diff --git a/web/discopanel/src/lib/components/auth-settings.svelte b/web/discopanel/src/lib/components/auth-settings.svelte index a848c97..8900a41 100644 --- a/web/discopanel/src/lib/components/auth-settings.svelte +++ b/web/discopanel/src/lib/components/auth-settings.svelte @@ -1,340 +1,388 @@ - -
- -
-
- -
-
- Authentication Settings - - Configure user authentication and access control - -
-
-
- - {#if loading} -
-
- -
Loading authentication settings...
+{#if loading} +
+ +
+{:else if config} +
+ + +
+ +
+
+ +
+
+ Authentication Settings + + Manage login methods and access controls + +
-
- {:else} -
- -
+ + + +
- -

- Require users to log in to access DiscoPanel -

+ +

Username and password login

- authConfig.enabled = checked} - disabled={saving} - /> -
- - {#if authConfig.enabled} - -
- - { localAuthEnabled = v; if (!v) allowRegistration = false; }} disabled={saving} - class="max-w-xs" /> -

- How long users stay logged in (default: 24 hours) -

+ {:else} + + {#if localAuthEnabled} + Enabled + {:else} + Disabled + {/if} + + {/if} +
+ + +
+
+ +

Allow new users to self-register

- - -
-
- -

- Allow new users to create accounts (they'll have viewer role by default) -

-
+ {#if canEdit} authConfig.allowRegistration = checked} + checked={allowRegistration} + onCheckedChange={(v) => { allowRegistration = v; }} + disabled={saving || !localAuthEnabled} + /> + {:else} + + {#if allowRegistration} + Allowed + {:else} + Disabled + {/if} + + {/if} +
+ + +
+
+ +

Limited unauthenticated browsing

+
+ {#if canEdit} + { anonymousAccess = v; }} disabled={saving} /> + {:else} + + {#if anonymousAccess} + Enabled + {:else} + Disabled + {/if} + + {/if} +
+ + +
+
+ +

How long sessions remain valid

- - {#if userCount > 0} - -
-
- - User Statistics -
-

- Total users: {userCount} -

+ {#if canEdit} +
+ + hours
- {/if} - - - - - - A recovery key will be generated and saved to the server's data directory when authentication is enabled. - Keep this key secure - it can be used to reset any user's password. - - - {/if} -
- -
- -
- {/if} - - +
- - showFirstUserDialog = open}> - - - Create Admin Account - - Create the first admin account to enable authentication. This account will have full system access. - - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - - + {/if} + + + + + +
+ +
+
+ +
+
+
+ OIDC / Single Sign-On + + External identity provider integration + +
+ {#if config.oidcEnabled} + + Connected + + {/if} +
+
+
+ + {#if config.oidcEnabled} + +
+ {#if config.oidcIssuerUri} +
+ +

{config.oidcIssuerUri}

+
+ {/if} + + {#if config.oidcClientId} +
+ +

{config.oidcClientId}

+
+ {/if} + + {#if config.oidcRedirectUrl} +
+ +

{config.oidcRedirectUrl}

+
+ {/if} + + {#if config.oidcScopes && config.oidcScopes.length > 0} +
+ +
+ {#each config.oidcScopes as scope} + {scope} + {/each} +
+
+ {/if} + + {#if config.oidcRoleClaim} +
+ +

{config.oidcRoleClaim}

+
+ {/if} +
{:else} - Create Admin & Enable Auth + +
+ +

+ OIDC allows users to sign in with external identity providers like Keycloak, Authelia, Google, or any OpenID Connect compatible service. +

+

+ OIDC must be configured outside the UI using one of the methods below. +

+
+ + + + + + + config.yaml + + + +
auth:
+  oidc:
+    enabled: true
+    issuer_uri: "https://your-provider/.well-known/openid-configuration"
+    client_id: "discopanel"
+    client_secret: "your-secret"
+    redirect_url: "https://your-domain/api/v1/auth/oidc/callback"
+    scopes: ["openid", "profile", "email", "groups"]
+    role_claim: "groups"
+
+
+ + + + + + Docker Compose + + + +
services:
+  discopanel:
+    environment:
+      DISCOPANEL_AUTH_OIDC_ENABLED: "true"
+      DISCOPANEL_AUTH_OIDC_ISSUER_URI: "https://..."
+      DISCOPANEL_AUTH_OIDC_CLIENT_ID: "discopanel"
+      DISCOPANEL_AUTH_OIDC_CLIENT_SECRET: "your-secret"
+      DISCOPANEL_AUTH_OIDC_REDIRECT_URL: "https://..."
+      DISCOPANEL_AUTH_OIDC_SCOPES: "openid,profile,email,groups"
+      DISCOPANEL_AUTH_OIDC_ROLE_CLAIM: "groups"
+
+
+ + + + + + Environment Variables + + + +
+

DISCOPANEL_AUTH_OIDC_ENABLED

+

DISCOPANEL_AUTH_OIDC_ISSUER_URI

+

DISCOPANEL_AUTH_OIDC_CLIENT_ID

+

DISCOPANEL_AUTH_OIDC_CLIENT_SECRET

+

DISCOPANEL_AUTH_OIDC_REDIRECT_URL

+

DISCOPANEL_AUTH_OIDC_SCOPES

+

DISCOPANEL_AUTH_OIDC_ROLE_CLAIM

+
+
+
+
+ +

+ Provider examples are available in the discopanel docs. +

{/if} - -
-
-
\ No newline at end of file + + +
+{/if} diff --git a/web/discopanel/src/lib/components/role-settings.svelte b/web/discopanel/src/lib/components/role-settings.svelte new file mode 100644 index 0000000..772dff8 --- /dev/null +++ b/web/discopanel/src/lib/components/role-settings.svelte @@ -0,0 +1,701 @@ + + +
+
+

Manage roles and their permissions

+ {#if canCreate} + + {/if} +
+ + + + {#if loading} +
+
+ +
Loading roles...
+
+
+ {:else} + + + + Name + Description + Type + Default + Permissions + {#if canDelete} + Actions + {/if} + + + + {#each roles as role} + + {role.name} + {role.description || '-'} + + {#if role.isSystem} + System + {:else} + Custom + {/if} + + + {#if role.isDefault} + + {:else} + + {/if} + + + {#if hasFullAccess(role.name)} + Full Access + {:else if canUpdate} + + {:else} + {role.permissions?.length || 0} permissions + {/if} + + {#if canDelete} + + {#if !role.isSystem} + + {/if} + + {/if} + + {/each} + +
+ {/if} +
+
+
+ + + showCreateDialog = open}> + + + Create New Role + + Create a custom role with specific permissions. + + + +
+
+ + +
+
+ + +
+
+ newRoleForm.isDefault = checked} + /> + +
+
+ + + + + +
+
+ + + showPermissionsDialog = open}> + +
+ +
+ +
+
+
+ +
+
+

{editingRole?.name}

+

+ {editingRole?.isSystem ? 'System role' : 'Custom role'} +

+
+
+
+ + + + + +
+
+

Permission Model

+

+ Global permissions apply to all objects. Scoped permissions target specific objects like individual servers. +

+
+
+
+ + +
+ +
+
+

+ {#if activeSection === 'global'}Global Permissions + {:else}Scoped Permissions + {/if} +

+

+ {#if activeSection === 'global'}Toggle access to resources and their actions + {:else}Grant access to specific objects instead of all objects of a type + {/if} +

+
+ +
+ + +
+ {#if activeSection === 'global'} + +
+ + + + Resource + {#each allActions as action} + + {action} + + {/each} + + + + {#each resourceActions as ra} + {@const count = getResourcePermCount(ra.resource)} + {@const total = ra.actions.length} + + +
+ toggleResourceAll(ra.resource)} + /> + {formatResourceName(ra.resource)} + {#if count > 0} + {count}/{total} + {/if} +
+
+ {#each allActions as action} + {@const key = `${ra.resource}:${action}`} + {@const hasAction = ra.actions.includes(action)} + {@const checked = hasAction && (editingPermissions[key] || false)} + + {#if hasAction} +
+ togglePermission(key)} + /> +
+ {:else} + + {/if} +
+ {/each} +
+ {/each} +
+
+
+ + {:else} + + {#if scopeableResources.length === 0} +
+ +

No scopeable resources

+

+ No objects exist yet to scope permissions to. Create resources like servers first. +

+
+ {:else} +
+ +
+ {#each scopeableResources as res} + {@const objectCount = availableObjects.filter(o => o.resource === res).length} + {@const source = scopeSourceMap[res]} + {@const isForeign = source && source !== res} + + {/each} +
+ + {#if scopedResource} + {@const activeSource = scopeSourceMap[scopedResource]} + {@const activeForeign = activeSource && activeSource !== scopedResource} + {#if filteredObjects.length === 0} +
+ +

No {formatResourceName(activeForeign ? activeSource : scopedResource)}

+

+ {#if activeForeign} + No {formatResourceName(activeSource)} exist to scope {formatResourceName(scopedResource)} permissions. + {:else} + No {formatResourceName(scopedResource)} exist yet to scope permissions to. + {/if} +

+
+ {:else} +
+ + + + + {formatResourceName(activeForeign ? activeSource : scopedResource)} + {#if activeForeign} +
scoping {formatResourceName(scopedResource)}
+ {/if} +
+ {#each scopedResourceActions as action} + {@const coveredByGlobal = editingPermissions[`${scopedResource}:${action}`] || false} + + {action} + {#if coveredByGlobal} +
(global)
+ {/if} +
+ {/each} +
+
+ + {#each filteredObjects as obj} + + + {obj.name} + + {#each scopedResourceActions as action} + {@const coveredByGlobal = editingPermissions[`${scopedResource}:${action}`] || false} + {@const checked = isScopedChecked(scopedResource, action, obj.id)} + +
+ toggleScopedPermission(scopedResource, action, obj.id, obj.name)} + /> +
+
+ {/each} +
+ {/each} +
+
+
+ {/if} + {:else} +
+ +

Select a resource type above to manage scoped permissions

+
+ {/if} +
+ {/if} + {/if} +
+ + +
+ + +
+
+
+
+
diff --git a/web/discopanel/src/lib/components/user-settings.svelte b/web/discopanel/src/lib/components/user-settings.svelte new file mode 100644 index 0000000..451dd70 --- /dev/null +++ b/web/discopanel/src/lib/components/user-settings.svelte @@ -0,0 +1,922 @@ + + +
+
+

Manage user accounts, role assignments, and invite links

+
+ {#if canCreate} + + + {/if} +
+
+ +
+ + + + {#if loading} +
+
+ +
Loading users...
+
+
+ {:else if users.length === 0} +
+
+ +
No users found
+
+
+ {:else} + + + + Username + Email + Provider + Roles + Status + Created + {#if canUpdate || canDelete} + Actions + {/if} + + + + {#each users as user} + + {user.username} + {user.email || '-'} + + {user.authProvider || 'local'} + + +
+ {#each user.roles || [] as role} + + {role} + + {/each} + {#if !user.roles?.length} + None + {/if} +
+
+ + {#if user.isActive} + Active + {:else} + Inactive + {/if} + + + {user.createdAt ? formatDate(new Date(Number(user.createdAt.seconds) * 1000).toISOString()) : 'Unknown'} + + {#if canUpdate || canDelete} + +
+ {#if canUpdate} + + {/if} + {#if canDelete} + + {/if} +
+
+ {/if} +
+ {/each} +
+
+ {/if} +
+
+ + + + +
+ +
+ Invite Links + Controlled registration via shareable URLs +
+
+
+ + {#if invitesLoading} +
+ +
+ {:else if invites.length === 0} +
+ +

+ No invite links yet. Create one to allow controlled registration. +

+
+ {:else} +
+ {#each invites as invite (invite.id)} + {@const status = getInviteStatus(invite)} +
+
+
+ {invite.description || 'Untitled'} + + {status} + + {#if invite.hasPin} + PIN + {/if} +
+
+ + {#if canDelete} + + {/if} +
+
+
+ {invite.useCount}{invite.maxUses > 0 ? `/${invite.maxUses}` : '/\u221e'} uses + {#if invite.roles && invite.roles.length > 0} + | + {invite.roles.join(', ')} + {/if} + {#if invite.expiresAt} + | + {new Date(Number(invite.expiresAt.seconds) * 1000).toLocaleDateString()} + {/if} +
+
+ {/each} +
+ {/if} +
+
+
+
+ + + showCreateDialog = open}> + +
+ +
+
+
+
+ +
+
+

New User

+

Local account

+
+
+
+ +
+
+
+ +

Creates a local account with password authentication.

+
+
+ +

Assign roles to control what the user can access.

+
+
+ +

Email is optional and used for display purposes only.

+
+
+
+ +
+
+

Password policy

+

+ Passwords must be at least 8 characters. The user can change their password later. +

+
+
+
+ + +
+
+
+

Create User

+

Add a new user to the system

+
+ +
+ +
+
+
+ + +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+
+ {#each availableRoles as role} + + {/each} +
+
+
+
+ +
+ + +
+
+
+
+
+ + + showEditDialog = open}> + +
+ +
+
+
+
+ +
+
+

{editingUser?.username ?? 'User'}

+

{editingUser?.authProvider || 'local'} account

+
+
+
+ +
+ {#if editingUser} +
+
+ +
+

Current roles

+
+ {#each editingUser.roles || [] as role} + {role} + {/each} + {#if !editingUser.roles?.length} + None + {/if} +
+
+
+
+ +
+

Created

+

+ {editingUser.createdAt ? formatDate(new Date(Number(editingUser.createdAt.seconds) * 1000).toISOString()) : 'Unknown'} +

+
+
+
+ {/if} +
+ +
+
+

User management

+

+ Changes to roles and status take effect immediately. The username cannot be changed. +

+
+
+
+ + +
+
+
+

Edit User

+

Update account details and permissions

+
+ +
+ +
+ {#if editingUser} +
+
+ + +
+
+
+ + +
+ +
+
+
+ + +
+
+ {#each availableRoles as role} + + {/each} +
+
+
+
+ +

Inactive accounts cannot log in

+
+ editUserForm.isActive = checked} + /> +
+
+ {/if} +
+ +
+ + +
+
+
+
+
+ + + showCreateInviteDialog = open}> + +
+ +
+ +
+
+
+ +
+
+

New Invite

+

Registration link

+
+
+
+ + +
+
+
+ +

Generates a unique URL that allows someone to register an account.

+
+
+ +

Assigned roles are applied automatically on registration.

+
+
+ +

Optional PIN adds an extra layer of protection.

+
+
+
+ + +
+
+

How it works

+

+ After creation, the invite URL is copied to your clipboard. Share it with the person you want to invite. +

+
+
+
+ + +
+ +
+
+

Create Invite

+

Configure invite settings and restrictions

+
+ +
+ + +
+
+
+ + +
+ +
+ +

Assigned on registration. If none selected, default roles are used.

+
+ {#each inviteRoleNames as role} + + {/each} +
+
+ +
+
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+ +

If set, users must enter this PIN to use the invite.

+
+
+
+ + +
+ + +
+
+
+
+
diff --git a/web/discopanel/src/lib/stores/auth.ts b/web/discopanel/src/lib/stores/auth.ts index 9b00be6..b35ecf8 100644 --- a/web/discopanel/src/lib/stores/auth.ts +++ b/web/discopanel/src/lib/stores/auth.ts @@ -3,34 +3,53 @@ import { goto } from '$app/navigation'; import { browser } from '$app/environment'; import { create } from '@bufbuild/protobuf'; import { rpcClient } from '$lib/api/rpc-client'; -import type { User } from '$lib/proto/discopanel/v1/common_pb'; -import { UserRole } from '$lib/proto/discopanel/v1/common_pb'; +import type { User, Permission } from '$lib/proto/discopanel/v1/common_pb'; import { LoginRequestSchema, RegisterRequestSchema, - ResetPasswordRequestSchema, ChangePasswordRequestSchema } from '$lib/proto/discopanel/v1/auth_pb'; interface AuthState { user: User | null; token: string | null; + permissions: Permission[]; isAuthenticated: boolean; isLoading: boolean; - authEnabled: boolean; + localAuthEnabled: boolean; + oidcEnabled: boolean; firstUserSetup: boolean; allowRegistration: boolean; + anonymousAccessEnabled: boolean; +} + +/** Check if a permission list grants access for the given resource/action/objectId. */ +function checkPermission( + permissions: Permission[], + resource: string, + action: string, + objectId?: string +): boolean { + return permissions.some( + (p) => + (p.resource === '*' || p.resource === resource) && + (p.action === '*' || p.action === action) && + (p.objectId === '*' || !objectId || p.objectId === objectId) + ); } function createAuthStore() { const { subscribe, set, update } = writable({ user: null, token: null, + permissions: [], isAuthenticated: false, isLoading: true, - authEnabled: false, + localAuthEnabled: true, + oidcEnabled: false, firstUserSetup: false, allowRegistration: false, + anonymousAccessEnabled: false, }); // Load token from localStorage on init @@ -43,16 +62,20 @@ function createAuthStore() { return { subscribe, - + async checkAuthStatus() { try { const response = await rpcClient.auth.getAuthStatus({}); + const authEnabled = response.localAuthEnabled || response.oidcEnabled; + update(state => ({ ...state, - authEnabled: response.enabled, + localAuthEnabled: response.localAuthEnabled, + oidcEnabled: response.oidcEnabled, firstUserSetup: response.firstUserSetup, allowRegistration: response.allowRegistration, + anonymousAccessEnabled: response.anonymousAccessEnabled, })); // If auth is enabled and we have a token, validate it @@ -62,14 +85,33 @@ function createAuthStore() { return state; }); - if (response.enabled && currentToken) { + if (!authEnabled) { + // Auth is disabled - backend grants full admin access, fetch permissions + await rpcClient.auth.getCurrentUser({}).then(r => update(state => ({ + ...state, + user: r.user || null, + permissions: r.permissions ?? [], + isLoading: false, + }))).catch(() => update(state => ({ ...state, isLoading: false }))); + } else if (currentToken) { await this.validateSession(); + } else if (response.anonymousAccessEnabled) { + try { + const r = await rpcClient.auth.getCurrentUser({}); + update(state => ({ + ...state, + permissions: r.permissions ?? [], + isLoading: false, + })); + } catch { + update(state => ({ ...state, isLoading: false })); + } } else { update(state => ({ ...state, isLoading: false })); } return { - enabled: response.enabled, + enabled: authEnabled, firstUserSetup: response.firstUserSetup, allowRegistration: response.allowRegistration }; @@ -79,7 +121,7 @@ function createAuthStore() { return { enabled: false, firstUserSetup: false, allowRegistration: false }; } }, - + async login(username: string, password: string) { try { const request = create(LoginRequestSchema, { username, password }); @@ -98,28 +140,18 @@ function createAuthStore() { isLoading: false, })); + // Fetch permissions after login + await this.validateSession(); + return response; } catch (error) { update(state => ({ ...state, isLoading: false })); throw error; } }, - - async logout() { - let currentState: AuthState = { - user: null, - token: null, - isAuthenticated: false, - isLoading: false, - authEnabled: false, - firstUserSetup: false, - allowRegistration: false, - }; - update(state => { - currentState = state; - return state; - }); + async logout() { + let currentState: AuthState = get({ subscribe }); try { if (currentState.token) { @@ -138,29 +170,41 @@ function createAuthStore() { set({ user: null, token: null, + permissions: [], isAuthenticated: false, isLoading: false, - authEnabled: currentState.authEnabled, + localAuthEnabled: currentState.localAuthEnabled, + oidcEnabled: currentState.oidcEnabled, firstUserSetup: currentState.firstUserSetup, allowRegistration: currentState.allowRegistration, + anonymousAccessEnabled: currentState.anonymousAccessEnabled, }); // Redirect to login goto('/login'); }, - - async register(username: string, email: string, password: string) { - try { - const request = create(RegisterRequestSchema, { username, email, password }); - await rpcClient.auth.register(request); - // After successful registration, log them in - return await this.login(username, password); - } catch (error) { - throw error; + async register(username: string, email: string, password: string, inviteCode?: string, invitePin?: string) { + const request = create(RegisterRequestSchema, { + username, + email, + password, + inviteCode: inviteCode || undefined, + invitePin: invitePin || undefined, + }); + await rpcClient.auth.register(request); + + // After successful registration, log them in + return await this.login(username, password); + }, + + setToken(token: string) { + if (browser) { + localStorage.setItem('auth_token', token); } + update(state => ({ ...state, token })); }, - + async changePassword(oldPassword: string, newPassword: string) { try { const request = create(ChangePasswordRequestSchema, { @@ -173,21 +217,7 @@ function createAuthStore() { throw new Error(error.message || 'Failed to change password'); } }, - - async resetPassword(username: string, recoveryKey: string, newPassword: string) { - try { - const request = create(ResetPasswordRequestSchema, { - username, - recoveryKey, - newPassword - }); - const response = await rpcClient.auth.resetPassword(request); - return response; - } catch (error: any) { - throw new Error(error.message || 'Failed to reset password'); - } - }, - + async validateSession() { let currentToken: string | null = null; update(state => { @@ -207,6 +237,7 @@ function createAuthStore() { update(state => ({ ...state, user: response.user || null, + permissions: response.permissions || [], isAuthenticated: true, isLoading: false, })); @@ -220,6 +251,7 @@ function createAuthStore() { ...state, user: null, token: null, + permissions: [], isAuthenticated: false, isLoading: false, })); @@ -235,26 +267,32 @@ function createAuthStore() { ...state, user: null, token: null, + permissions: [], isAuthenticated: false, isLoading: false, })); return false; } }, - + getHeaders() { - let currentToken: string | null = null; - update(state => { - currentToken = state.token; - return state; - }); - + const state = get({ subscribe }); const headers: HeadersInit = {}; - if (currentToken) { - headers['Authorization'] = `Bearer ${currentToken}`; + if (state.token) { + headers['Authorization'] = `Bearer ${state.token}`; } return headers; }, + + hasRole(role: string): boolean { + const state = get({ subscribe }); + return state.user?.roles?.includes(role) ?? false; + }, + + hasPermission(resource: string, action: string, objectId?: string): boolean { + const state = get({ subscribe }); + return checkPermission(state.permissions, resource, action, objectId); + }, }; } @@ -263,9 +301,29 @@ export const authStore = createAuthStore(); // Derived stores for convenience export const isAuthenticated = derived(authStore, $auth => $auth.isAuthenticated); export const currentUser = derived(authStore, $auth => $auth.user); -export const isAdmin = derived(authStore, $auth => $auth.user?.role === UserRole.ADMIN); -export const isEditor = derived(authStore, $auth => $auth.user?.role === UserRole.EDITOR || $auth.user?.role === UserRole.ADMIN); -export const authEnabled = derived(authStore, $auth => $auth.authEnabled); +export const userPermissions = derived(authStore, $auth => $auth.permissions); +export const authEnabled = derived(authStore, $auth => $auth.localAuthEnabled || $auth.oidcEnabled); + +// Permission-based derived stores +export const canReadUsers = derived(authStore, $auth => checkPermission($auth.permissions, 'users', 'read')); +export const canCreateUsers = derived(authStore, $auth => checkPermission($auth.permissions, 'users', 'create')); +export const canUpdateUsers = derived(authStore, $auth => checkPermission($auth.permissions, 'users', 'update')); +export const canDeleteUsers = derived(authStore, $auth => checkPermission($auth.permissions, 'users', 'delete')); +export const canReadRoles = derived(authStore, $auth => checkPermission($auth.permissions, 'roles', 'read')); +export const canCreateRoles = derived(authStore, $auth => checkPermission($auth.permissions, 'roles', 'create')); +export const canUpdateRoles = derived(authStore, $auth => checkPermission($auth.permissions, 'roles', 'update')); +export const canDeleteRoles = derived(authStore, $auth => checkPermission($auth.permissions, 'roles', 'delete')); +export const canReadSettings = derived(authStore, $auth => checkPermission($auth.permissions, 'settings', 'read')); +export const canUpdateSettings = derived(authStore, $auth => checkPermission($auth.permissions, 'settings', 'update')); +export const canReadServers = derived(authStore, $auth => checkPermission($auth.permissions, 'servers', 'read')); +export const canCreateServers = derived(authStore, $auth => checkPermission($auth.permissions, 'servers', 'create')); +export const canReadModpacks = derived(authStore, $auth => checkPermission($auth.permissions, 'modpacks', 'read')); + +// Check if user has any settings-adjacent permission (for sidebar visibility) +export const canAccessSettings = derived(authStore, $auth => + checkPermission($auth.permissions, 'settings', 'read') || + checkPermission($auth.permissions, 'users', 'read') || + checkPermission($auth.permissions, 'roles', 'read') +); -// Make auth store values accessible as a readable store -export const $authStore = derived(authStore, $auth => $auth); \ No newline at end of file +export { checkPermission }; diff --git a/web/discopanel/src/lib/stores/servers.ts b/web/discopanel/src/lib/stores/servers.ts index 3ca4ad5..f89b351 100644 --- a/web/discopanel/src/lib/stores/servers.ts +++ b/web/discopanel/src/lib/stores/servers.ts @@ -10,6 +10,7 @@ function createServersStore() { return { subscribe, + set, fetchServers: async (skipLoading = false) => { try { const request = create(ListServersRequestSchema, { fullStats: false }); @@ -26,9 +27,9 @@ function createServersStore() { update(servers => { const index = servers.findIndex(s => s.id === server.id); if (index !== -1) { - servers[index] = server; + return [...servers.slice(0, index), server, ...servers.slice(index + 1)]; } - return servers; + return [...servers, server]; }); }, removeServer: (id: string) => { diff --git a/web/discopanel/src/lib/utils/role-colors.ts b/web/discopanel/src/lib/utils/role-colors.ts new file mode 100644 index 0000000..bdaa0bd --- /dev/null +++ b/web/discopanel/src/lib/utils/role-colors.ts @@ -0,0 +1,16 @@ +/** Badge variant type matching the UI component's expected variants. */ +type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline'; + +const BADGE_VARIANTS: BadgeVariant[] = ['default', 'secondary', 'destructive', 'outline']; + +/** + * Deterministically maps a role name to a badge variant using a simple hash. + * Works for any dynamic role name without hardcoding specific names. + */ +export function getRoleBadgeVariant(roleName: string): BadgeVariant { + let hash = 0; + for (let i = 0; i < roleName.length; i++) { + hash = ((hash << 5) - hash + roleName.charCodeAt(i)) | 0; + } + return BADGE_VARIANTS[Math.abs(hash) % BADGE_VARIANTS.length]; +} diff --git a/web/discopanel/src/routes/+layout.svelte b/web/discopanel/src/routes/+layout.svelte index d91ea4c..ca07918 100644 --- a/web/discopanel/src/routes/+layout.svelte +++ b/web/discopanel/src/routes/+layout.svelte @@ -30,31 +30,36 @@ DropdownMenuSeparator, DropdownMenuTrigger } from '$lib/components/ui/dropdown-menu'; + import { get } from 'svelte/store'; import { serversStore, runningServers } from '$lib/stores/servers'; - import { authStore, currentUser, isAdmin } from '$lib/stores/auth'; + import { authStore, currentUser, canAccessSettings, authEnabled } from '$lib/stores/auth'; import { onMount } from 'svelte'; import { Toaster } from '$lib/components/ui/sonner'; import GlobalLoading from '$lib/components/global-loading.svelte'; - import { Server, Home, Settings, Package, User, Users, LogOut, FileText, Sun, Moon } from '@lucide/svelte'; + import { Server, Home, Settings, Package, User, LogOut, LogIn, FileText, Sun, Moon } from '@lucide/svelte'; import { toggleMode, mode } from 'mode-watcher'; - import { ServerStatus, UserRole } from '$lib/proto/discopanel/v1/common_pb'; - import { getStringForEnum } from '$lib/utils'; + import { ServerStatus } from '$lib/proto/discopanel/v1/common_pb'; let { children } = $props(); let servers = $derived($serversStore); let runningCount = $derived($runningServers.length); let user = $derived($currentUser); - let isUserAdmin = $derived($isAdmin); + let showSettingsNav = $derived($canAccessSettings); let loading = $state(true); - let isAuthEnabled = $derived($authStore.authEnabled); + let isAuthEnabled = $derived($authEnabled); function getUserInitials(user: any) { if (!user) return ''; return user.username.slice(0, 2).toUpperCase(); } + function getDisplayRole(user: any): string { + if (!user?.roles?.length) return 'No roles'; + return user.roles[0]; + } + async function handleLogout() { await authStore.logout(); } @@ -72,8 +77,12 @@ } const isValid = await authStore.validateSession(); if (!isValid) { - goto('/login'); - return; + // If anonymous access is enabled, allow browsing without login + const state = get(authStore); + if (!state.anonymousAccessEnabled) { + goto('/login'); + return; + } } } }).then(() => { @@ -173,28 +182,18 @@ {/snippet} - {#if isUserAdmin} + {#if showSettingsNav} - + {#snippet child({ props })} - - - Users + + + Settings {/snippet} {/if} - - - {#snippet child({ props })} - - - Settings - - {/snippet} - - {#snippet child({ props })} @@ -259,7 +258,7 @@

{user.username}

-

{getStringForEnum(UserRole, user.role)}

+

{getDisplayRole(user)}

{/snippet} @@ -272,7 +271,9 @@ {#if user.email}

{user.email}

{/if} -

Role: {user.role}

+

+ {user.roles?.length ? user.roles.join(', ') : 'No roles'} +

@@ -280,20 +281,21 @@ Profile - {#if isUserAdmin} - - goto('/users')}> - - Manage Users - - {/if} Log out - + +
+ {:else if isAuthEnabled && $authStore.anonymousAccessEnabled && !user} + +
+
{/if} diff --git a/web/discopanel/src/routes/+page.svelte b/web/discopanel/src/routes/+page.svelte index 6e09abe..0760389 100644 --- a/web/discopanel/src/routes/+page.svelte +++ b/web/discopanel/src/routes/+page.svelte @@ -16,18 +16,19 @@ } from '@lucide/svelte'; import { ServerStatus, type Server as ServerType } from '$lib/proto/discopanel/v1/common_pb'; import { rpcClient } from '$lib/api/rpc-client'; + import { serversStore } from '$lib/stores/servers'; - // Dashboard data - not from the polling store + // Dashboard data let dashboardServers: ServerType[] = $state([]); let isLoading = $state(true); let isRefreshing = $state(false); let currentTime = $state(new Date()); - // Load dashboard data with full stats async function loadDashboardData() { try { const response = await rpcClient.server.listServers({ fullStats: true }); dashboardServers = response.servers; + serversStore.set(response.servers); } catch (error) { console.error('Failed to load dashboard data:', error); } diff --git a/web/discopanel/src/routes/docs/api/+page.svelte b/web/discopanel/src/routes/docs/api/+page.svelte index 3c1b65b..8b8864c 100644 --- a/web/discopanel/src/routes/docs/api/+page.svelte +++ b/web/discopanel/src/routes/docs/api/+page.svelte @@ -46,7 +46,7 @@ window.addEventListener('load', () => { window.parent.postMessage({ type: 'scalar-progress', value: 50 }, '*'); window.Scalar.createApiReference('#api-reference', { - url: '/schemav1.yaml', + url: '/api/v1/openapi.yaml', hideClientButton: true, showDeveloperTools: 'never', showToolbar: 'never' diff --git a/web/discopanel/src/routes/login/+page.svelte b/web/discopanel/src/routes/login/+page.svelte index af7b134..b657bdf 100644 --- a/web/discopanel/src/routes/login/+page.svelte +++ b/web/discopanel/src/routes/login/+page.svelte @@ -1,22 +1,25 @@ +{#snippet loginForm()} +
+ {#if localAuthEnabled} +
+
+ + +
+
+ + +
+ +
+ {/if} + + {#if oidcEnabled} + {#if localAuthEnabled} +
+
+ +
+
+ Or +
+
+ {/if} + + {/if} +
+{/snippet} + +{#snippet registerForm()} +
+ {#if inviteValid && inviteDescription} + + + {inviteDescription} + + {/if} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {#if inviteValid && inviteRequiresPin} +
+ + +
+ {/if} + +
+{/snippet} +
@@ -144,7 +325,7 @@ {/if} - + {#if error} @@ -153,159 +334,7 @@ {/if} - {#if !authStatus.firstUserSetup} - - - Login - {#if authStatus.allowRegistration} - Register - {/if} - Reset - - - -
-
- - -
-
- - -
- -
-
- - {#if authStatus.allowRegistration} - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
- {/if} - - -
-

- Enter your username, recovery key, and new password to reset your account. -

-
- - -
-
- - -
-
- - -
- -
-
-
- {:else} + {#if authStatus.firstUserSetup}
@@ -354,8 +383,11 @@ - This will be the admin account with full system access. - A recovery key will be generated and saved for password recovery. + {#if oidcEnabled} + A local admin account is required for initial setup, even with SSO enabled. This ensures you always have a fallback login to manage the system if your identity provider becomes unavailable. + {:else} + This will be the admin account with full system access. + {/if} + {:else if (authStatus.allowRegistration || inviteValid) && localAuthEnabled} + + + + Login + Register + + + + {@render loginForm()} + + + + {@render registerForm()} + + + {:else} + + {@render loginForm()} + {/if} + + {#if $authStore.anonymousAccessEnabled} +
+
+ +
+
+ Or +
+
+ {/if} -
\ No newline at end of file +
diff --git a/web/discopanel/src/routes/profile/+page.svelte b/web/discopanel/src/routes/profile/+page.svelte index cc23f43..62f6fb9 100644 --- a/web/discopanel/src/routes/profile/+page.svelte +++ b/web/discopanel/src/routes/profile/+page.svelte @@ -5,10 +5,16 @@ import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; import { Badge } from '$lib/components/ui/badge'; + import { Dialog, DialogContent } from '$lib/components/ui/dialog'; + import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select'; + import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '$lib/components/ui/table'; import { toast } from 'svelte-sonner'; - import { User, Key, Shield, Edit, Eye, Loader2 } from '@lucide/svelte'; - import { UserRole } from '$lib/proto/discopanel/v1/common_pb'; - + import { User, Key, Loader2, Mail, Calendar, Clock, Shield, Activity, Plus, Trash2, Copy, X, Check, AlertTriangle, KeyRound } from '@lucide/svelte'; + import { getRoleBadgeVariant } from '$lib/utils/role-colors'; + import { rpcClient, silentCallOptions } from '$lib/api/rpc-client'; + import { onMount } from 'svelte'; + import type { ApiToken } from '$lib/proto/discopanel/v1/auth_pb'; + let user = $derived($currentUser); let passwordForm = $state({ oldPassword: '', @@ -16,23 +22,154 @@ confirmPassword: '' }); let saving = $state(false); - + + // API Tokens state + let apiTokens = $state([]); + let loadingTokens = $state(false); + let showCreateTokenDialog = $state(false); + let creatingToken = $state(false); + let newTokenForm = $state({ name: '', expiresInDays: '' as string }); + let createdToken = $state(null); + let copied = $state(false); + let deletingTokenId = $state(null); + + let initials = $derived( + user?.username + ? user.username + .split(/[\s_-]+/) + .slice(0, 2) + .map((w) => w[0]?.toUpperCase() ?? '') + .join('') + : '?' + ); + + let primaryRole = $derived(user?.roles?.[0] ?? 'user'); + + let memberSince = $derived( + user?.createdAt + ? new Date(Number(user.createdAt.seconds) * 1000).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + : 'Unknown' + ); + + let lastActive = $derived( + user?.lastLogin + ? new Date(Number(user.lastLogin.seconds) * 1000).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + : null + ); + + let providerLabel = $derived((user?.authProvider || 'local').toUpperCase()); + + onMount(() => { + loadTokens(); + }); + + async function loadTokens() { + loadingTokens = true; + try { + const resp = await rpcClient.auth.listAPITokens({}, silentCallOptions); + apiTokens = resp.apiTokens; + } catch { + // silently fail - tokens will show empty + } finally { + loadingTokens = false; + } + } + + async function createToken() { + if (!newTokenForm.name.trim()) { + toast.error('Token name is required'); + return; + } + + creatingToken = true; + try { + const days = newTokenForm.expiresInDays ? parseInt(newTokenForm.expiresInDays) : undefined; + const resp = await rpcClient.auth.createAPIToken({ + name: newTokenForm.name.trim(), + expiresInDays: days + }); + createdToken = resp.plaintextToken; + toast.success('API token created'); + await loadTokens(); + } catch (error: any) { + toast.error(error.message || 'Failed to create API token'); + } finally { + creatingToken = false; + } + } + + async function deleteToken(id: string) { + deletingTokenId = id; + try { + await rpcClient.auth.deleteAPIToken({ id }); + toast.success('API token deleted'); + await loadTokens(); + } catch (error: any) { + toast.error(error.message || 'Failed to delete API token'); + } finally { + deletingTokenId = null; + } + } + + async function copyToken() { + if (!createdToken) return; + try { + await navigator.clipboard.writeText(createdToken); + copied = true; + toast.success('Token copied to clipboard'); + setTimeout(() => { copied = false; }, 2000); + } catch { + toast.error('Failed to copy token'); + } + } + + function closeCreateDialog() { + showCreateTokenDialog = false; + createdToken = null; + copied = false; + newTokenForm = { name: '', expiresInDays: '' }; + } + + function formatTimestamp(ts: { seconds: bigint } | undefined): string { + if (!ts) return 'Never'; + return new Date(Number(ts.seconds) * 1000).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } + + function isExpired(ts: { seconds: bigint } | undefined): boolean { + if (!ts) return false; + return new Date(Number(ts.seconds) * 1000) < new Date(); + } + async function changePassword() { if (!passwordForm.oldPassword || !passwordForm.newPassword) { toast.error('Please fill in all fields'); return; } - + if (passwordForm.newPassword !== passwordForm.confirmPassword) { toast.error('New passwords do not match'); return; } - + if (passwordForm.newPassword.length < 8) { toast.error('New password must be at least 8 characters'); return; } - + saving = true; try { await authStore.changePassword(passwordForm.oldPassword, passwordForm.newPassword); @@ -48,163 +185,500 @@ saving = false; } } - - function getRoleIcon(role: UserRole) { - switch (role) { - case UserRole.ADMIN: - return Shield; - case UserRole.EDITOR: - return Edit; - case UserRole.VIEWER: - return Eye; - default: - return Eye; - } - } - - function getRoleBadgeVariant(role: UserRole) { - switch (role) { - case UserRole.ADMIN: - return 'destructive' as const; - case UserRole.EDITOR: - return 'secondary' as const; - case UserRole.VIEWER: - return 'outline' as const; - default: - return 'outline' as const; - } - } - - function getRoleDisplayName(role: UserRole): string { - switch (role) { - case UserRole.ADMIN: - return 'Admin'; - case UserRole.EDITOR: - return 'Editor'; - case UserRole.VIEWER: - return 'Viewer'; - default: - return 'Unknown'; - } - }
-
-
- -
-
-

Profile

-

Manage your account settings

-
-
- {#if user} + +
+
+ {initials} +
+
+
+

{user.username}

+ {primaryRole} +
+

Manage your account settings and security

+
+
+
- - - - - - Account Information - - Your account details and role + + +
+ +
+
+ +
+
+ Account Information + Your account details and roles +
+
- -
- -

{user.username}

+ + +
+ +
+

Username

+

{user.username}

+
+
+ + +
+ +
+
+

Auth Provider

+

{providerLabel}

+
+
+ {providerLabel}
- + + {#if user.email} -
- -

{user.email}

+
+ +
+

Email

+

{user.email}

+
{/if} - -
- -
- - {@const Icon = getRoleIcon(user.role)} - - {getRoleDisplayName(user.role)} - + + +
+ +
+

Roles

+
+ {#each user.roles || [] as role} + {role} + {/each} + {#if !user.roles?.length} + No roles assigned + {/if} +
- -
- -

{user.createdAt ? new Date(Number(user.createdAt.seconds) * 1000).toLocaleDateString() : 'Unknown'}

-
- {#if user.updatedAt} + +
+
- -

{new Date(Number(user.updatedAt.seconds) * 1000).toLocaleString()}

+

Member since

+

{memberSince}

+
+
+ + + {#if lastActive} +
+ +
+

Last active

+

{lastActive}

+
{/if} + + +
+ +
+

Account status

+

{user.isActive ? 'Active' : 'Inactive'}

+
+ + {user.isActive ? 'Active' : 'Inactive'} + +
- - - - - - - Change Password - - Update your account password + + + +
+ +
+
+ +
+
+ Security + Password and session management +
+
- -
{ e.preventDefault(); changePassword(); }} class="space-y-4"> -
- - -
- -
- - -
- -
- - -
- - -
+ + +
+ +
+
+ Provider + {providerLabel} +
+
+
+ + + {#if user.authProvider === 'local' || !user.authProvider} +
+ +
{ e.preventDefault(); changePassword(); }} class="space-y-3"> +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ {:else} +
+
+ +

+ Your account uses {providerLabel} authentication. Password changes are managed by your identity provider. +

+
+
+ {/if}
+ + + +
+ +
+
+
+ +
+
+ API Tokens + Programmatic access tokens that inherit your identity and permissions +
+
+ +
+
+ + {#if loadingTokens} +
+ +
+ {:else if apiTokens.length === 0} +
+ +

No API tokens

+

Create a token to authenticate programmatically with the DiscoPanel API.

+
+ {:else} +
+ + + + Name + Created + Expires + Last Used + + + + + {#each apiTokens as token} + + +
+ + {token.name} +
+
+ + {formatTimestamp(token.createdAt)} + + + {#if token.expiresAt} + + {isExpired(token.expiresAt) ? 'Expired' : formatTimestamp(token.expiresAt)} + + {:else} + Never + {/if} + + + {formatTimestamp(token.lastUsedAt)} + + + + +
+ {/each} +
+
+
+ {/if} +
+
{/if} -
\ No newline at end of file +
+ + + { if (!open) closeCreateDialog(); }}> + +
+ +
+ +
+
+
+ +
+
+

New API Token

+

Programmatic access

+
+
+
+ + +
+
+
+ +

Tokens inherit your full identity, roles, and permissions.

+
+
+ +

Use tokens to authenticate API requests programmatically.

+
+
+ +

The token value is shown only once after creation.

+
+
+
+ + +
+
+

Usage

+

+ Authorization: Bearer dp_... +

+
+
+
+ + +
+ +
+
+

+ {createdToken ? 'Token Created' : 'Create API Token'} +

+

+ {createdToken ? 'Copy your token now — it won\'t be shown again' : 'Configure your new API token'} +

+
+ +
+ + +
+ {#if createdToken} + +
+
+
+ + Token created successfully +
+
+
+ {createdToken} +
+ +
+
+ +
+ +
+

This token will not be shown again

+

+ Make sure you copy it now. If you lose it, you'll need to create a new one. +

+
+
+ +
+

Example usage

+
curl {window.location.origin}/discopanel.v1.UserService/ListUsers \{"\n"}  -X POST \{"\n"}  -H 'Content-Type: application/json' \{"\n"}  -H 'Authorization: Bearer {createdToken}' \{"\n"}  -d '{"{}"}'
+
+
+ {:else} + +
+
+ + +

A descriptive name to help you identify this token.

+
+ +
+ + +

+ {newTokenForm.expiresInDays ? `Token will expire after ${newTokenForm.expiresInDays} days.` : 'Token will never expire. You can revoke it at any time.'} +

+
+
+ {/if} +
+ + +
+ {#if createdToken} + + + {:else} + + + {/if} +
+
+
+
+
diff --git a/web/discopanel/src/routes/settings/+page.svelte b/web/discopanel/src/routes/settings/+page.svelte index f0c02d8..a3c2a17 100644 --- a/web/discopanel/src/routes/settings/+page.svelte +++ b/web/discopanel/src/routes/settings/+page.svelte @@ -2,22 +2,43 @@ import { onMount } from 'svelte'; import ServerConfiguration from '$lib/components/server-configuration.svelte'; import ScrollToTop from '$lib/components/scroll-to-top.svelte'; + import UserSettings from '$lib/components/user-settings.svelte'; + import RoleSettings from '$lib/components/role-settings.svelte'; import { Card, CardContent } from '$lib/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs'; import { toast } from 'svelte-sonner'; - import { Settings, Globe, Server, Shield, HelpCircle, ScrollText } from '@lucide/svelte'; + import { Settings, Globe, Server, Shield, HelpCircle, ScrollText, Users, KeyRound } from '@lucide/svelte'; import type { ConfigCategory } from '$lib/proto/discopanel/v1/config_pb'; import { rpcClient } from '$lib/api/rpc-client'; import RoutingSettings from '$lib/components/routing-settings.svelte'; import AuthSettings from '$lib/components/auth-settings.svelte'; import SupportSettings from '$lib/components/support-settings.svelte'; import LogsSettings from '$lib/components/logs-settings.svelte'; - + import { + canReadSettings, + canReadUsers, + canReadRoles, + authEnabled, + } from '$lib/stores/auth'; + let globalConfig = $state([]); let loading = $state(true); let saving = $state(false); - let activeTab = $state('server-config'); - + + let showSettings = $derived($canReadSettings); + let showUsers = $derived($canReadUsers && $authEnabled); + let showRoles = $derived($canReadRoles && $authEnabled); + + // Pick the first visible tab as default + let activeTab = $state(''); + $effect(() => { + if (!activeTab) { + if (showSettings) activeTab = 'server-config'; + else if (showUsers) activeTab = 'users'; + else if (showRoles) activeTab = 'roles'; + } + }); + async function loadGlobalSettings() { loading = true; try { @@ -30,7 +51,7 @@ loading = false; } } - + async function saveGlobalSettings(updates: Record) { saving = true; try { @@ -47,9 +68,13 @@ saving = false; } } - + onMount(() => { - loadGlobalSettings(); + if (showSettings) { + loadGlobalSettings(); + } else { + loading = false; + } }); @@ -65,70 +90,98 @@
- - activeTab = v || 'server-config'} class="space-y-6"> - - - - Server Defaults - - - - Routing - - - - Authentication - - - - Logs - - - - Support - + + activeTab = v || activeTab} class="space-y-6"> + + {#if showSettings} + + + Server Defaults + + + + Routing + + + + Auth + + + + Logs + + + + Support + + {/if} + {#if showUsers} + + + Users + + {/if} + {#if showRoles} + + + Roles + + {/if} - - - {#if loading} - - -
-
-
- + + {#if showSettings} + + {#if loading} + + +
+
+
+ +
+
Loading settings...
-
Loading settings...
-
- - - {:else} - - {/if} - - - - - - - - - - - - - - - - - + + + {:else} + + {/if} + + + + + + + + + + + + + + + + + + {/if} + + {#if showUsers} + + + + {/if} + + {#if showRoles} + + + + {/if}
- \ No newline at end of file + diff --git a/web/discopanel/src/routes/users/+page.svelte b/web/discopanel/src/routes/users/+page.svelte deleted file mode 100644 index ae4193b..0000000 --- a/web/discopanel/src/routes/users/+page.svelte +++ /dev/null @@ -1,422 +0,0 @@ - - -
-
-
-
- -
-
-

User Management

-

Manage user accounts and permissions

-
-
- - -
- - - - {#if loading} -
-
- -
Loading users...
-
-
- {:else if users.length === 0} -
-
- -
No users found
-
-
- {:else} - - - - Username - Email - Role - Status - Created - Last Active - Actions - - - - {#each users as user} - - {user.username} - {user.email || '-'} - - {@const badge = getRoleBadge(user.role)} - {@const Icon = badge.icon} - - - {getRoleDisplayName(user.role)} - - - - {#if user.isActive} - Active - {:else} - Inactive - {/if} - - - {user.createdAt ? formatDate(new Date(Number(user.createdAt.seconds) * 1000).toISOString()) : 'Unknown'} - - - {user.updatedAt ? formatDate(new Date(Number(user.updatedAt.seconds) * 1000).toISOString()) : 'Never'} - - -
- - -
-
-
- {/each} -
-
- {/if} -
-
-
- - - showCreateDialog = open}> - - - Create New User - - Add a new user to the system with specific permissions. - - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - - - -
-
- - - showEditDialog = open}> - - - Edit User - - Update user information and permissions. - - - - {#if editingUser} -
-
- - -
-
- - -
-
- - -
-
- - -
-
- {/if} - - - - - -
-
\ No newline at end of file