From 9e4c612dcb605adcf929ec6e387df2c4a6a85490 Mon Sep 17 00:00:00 2001 From: backup Date: Wed, 2 Sep 2026 11:20:31 -0500 Subject: [PATCH] backup: pre-hardening baseline --- .gitignore | 8 + ARCHITECTURE.md | 314 ++ Makefile | 25 + README.md | 24 + backend/cmd/traefik-gui/main.go | 111 + backend/go.mod | 40 + backend/go.sum | 105 + backend/internal/api/handlers/auth.go | 143 + backend/internal/api/handlers/config.go | 138 + backend/internal/api/handlers/config_file.go | 146 + backend/internal/api/handlers/health.go | 53 + backend/internal/api/middleware/auth.go | 221 + backend/internal/api/routes.go | 4 + backend/internal/api/server.go | 164 + backend/internal/auth/session.go | 88 + backend/internal/config/adapters/docker.go | 43 + .../internal/config/adapters/kubernetes.go | 33 + backend/internal/config/file/diff.go | 84 + backend/internal/config/file/lock.go | 25 + backend/internal/config/file/service.go | 385 ++ backend/internal/config/file/service_test.go | 345 ++ backend/internal/config/file/validate.go | 290 ++ backend/internal/config/types.go | 11 + .../internal/database/repositories/session.go | 55 + .../internal/database/repositories/user.go | 56 + backend/internal/database/sqlite.go | 144 + backend/internal/models/traefik.go | 131 + backend/internal/models/user.go | 56 + backend/internal/traefik/client.go | 16 + backend/internal/traefik/mock.go | 358 ++ backend/pkg/version/version.go | 13 + docker/Dockerfile | 20 + docker/docker-compose.yml | 47 + docker/traefik.dev.yml | 16 + frontend/index.html | 13 + frontend/package-lock.json | 3951 +++++++++++++++++ frontend/package.json | 35 + frontend/src/App.tsx | 30 + frontend/src/api/client.ts | 276 ++ frontend/src/api/queryClient.ts | 11 + frontend/src/components/layout/Layout.tsx | 74 + frontend/src/components/ui/Toast.tsx | 2 + frontend/src/hooks/useAuth.tsx | 14 + frontend/src/index.css | 463 ++ frontend/src/main.tsx | 14 + frontend/src/pages/Certificates.tsx | 186 + frontend/src/pages/ConfigEditor.tsx | 522 +++ frontend/src/pages/Dashboard.tsx | 156 + frontend/src/pages/Login.tsx | 129 + frontend/src/pages/Middlewares.tsx | 179 + frontend/src/pages/Routers.tsx | 172 + frontend/src/pages/Services.tsx | 175 + frontend/src/pages/Settings.tsx | 213 + frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 25 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 30 + 57 files changed, 10393 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/cmd/traefik-gui/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/api/handlers/auth.go create mode 100644 backend/internal/api/handlers/config.go create mode 100644 backend/internal/api/handlers/config_file.go create mode 100644 backend/internal/api/handlers/health.go create mode 100644 backend/internal/api/middleware/auth.go create mode 100644 backend/internal/api/routes.go create mode 100644 backend/internal/api/server.go create mode 100644 backend/internal/auth/session.go create mode 100644 backend/internal/config/adapters/docker.go create mode 100644 backend/internal/config/adapters/kubernetes.go create mode 100644 backend/internal/config/file/diff.go create mode 100644 backend/internal/config/file/lock.go create mode 100644 backend/internal/config/file/service.go create mode 100644 backend/internal/config/file/service_test.go create mode 100644 backend/internal/config/file/validate.go create mode 100644 backend/internal/config/types.go create mode 100644 backend/internal/database/repositories/session.go create mode 100644 backend/internal/database/repositories/user.go create mode 100644 backend/internal/database/sqlite.go create mode 100644 backend/internal/models/traefik.go create mode 100644 backend/internal/models/user.go create mode 100644 backend/internal/traefik/client.go create mode 100644 backend/internal/traefik/mock.go create mode 100644 backend/pkg/version/version.go create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.yml create mode 100644 docker/traefik.dev.yml create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/queryClient.ts create mode 100644 frontend/src/components/layout/Layout.tsx create mode 100644 frontend/src/components/ui/Toast.tsx create mode 100644 frontend/src/hooks/useAuth.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Certificates.tsx create mode 100644 frontend/src/pages/ConfigEditor.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Middlewares.tsx create mode 100644 frontend/src/pages/Routers.tsx create mode 100644 frontend/src/pages/Services.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbfec79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +bin/ +node_modules/ +dist/ +data/ +*.db +.env +.idea/ +.vscode/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2a08b26 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,314 @@ +# Traefik GUI - Architecture Document + +## 1. Backend Directory Structure + +``` +backend/ +├── cmd/ +│ └── traefik-gui/ # Main entry point +│ └── main.go +├── internal/ +│ ├── api/ # REST API handlers +│ │ ├── handlers/ +│ │ │ ├── auth.go # Login, logout, session validation +│ │ │ ├── health.go # Health check endpoint +│ │ │ ├── config.go # File-provider config management (Phase 2) +│ │ │ └── traefik.go # Traefik status proxy (read-only) +│ │ ├── middleware/ +│ │ │ ├── auth.go # Session + CSRF validation +│ │ │ ├── cors.go # CORS handling (inline in auth.go) +│ │ │ └── logging.go # Request logging +│ │ ├── routes.go # Route registration +│ │ └── server.go # HTTP server setup +│ ├── auth/ # Authentication logic +│ │ ├── session.go # Session management (SQLite-backed) +│ │ ├── password.go # Password hashing (bcrypt) +│ │ └── csrf.go # CSRF protection helpers +│ ├── config/ +│ │ ├── file/ # File-provider service (Phase 2) +│ │ │ ├── service.go # Atomic writes, validation, backup, rollback +│ │ │ ├── validate.go # YAML/TOML validation via parser +│ │ │ ├── diff.go # Unified diff generation +│ │ │ └── lock.go # File-level advisory lock for concurrency +│ │ ├── adapters/ # Provider adapters (stubs Phase 2) +│ │ │ ├── docker.go # Docker labels adapter (stub) +│ │ │ └── kubernetes.go # Kubernetes CRD adapter (stub) +│ │ ├── env.go # Environment variable config +│ │ └── types.go # Config structs +│ ├── database/ # Database layer +│ │ ├── sqlite.go # SQLite connection & migrations +│ │ └── repositories/ +│ │ ├── user.go # User repository +│ │ └── session.go # Session repository +│ ├── models/ +│ │ ├── user.go +│ │ ├── session.go +│ │ └── traefik.go # Traefik resource models +│ └── traefik/ # Traefik read-only integration +│ ├── client.go # Traefik API client interface (read-only) +│ ├── mock.go # Mock implementation for local MVP +│ └── fileprovider.go # Deprecated: use config/file/service.go +├── pkg/ +│ └── version/ +│ └── version.go +├── go.mod +├── go.sum +└── Makefile +``` + +## 2. API Routes (Phase 2 — Exact) + +| Method | Path | Description | Auth | CSRF | Role | +|--------|------|-------------|------|------|------| +| GET | `/api/health` | Health check | No | No | — | +| GET | `/api/ready` | Readiness (DB + config dir writable) | No | No | — | +| POST | `/api/auth/login` | Login `{username,password}` | No | No | — | +| POST | `/api/auth/logout` | Logout + session delete | Yes | Yes | any | +| GET | `/api/auth/me` | Current user | Yes | No | any | +| GET | `/api/traefik/overview` | Read-only overview (mock or proxy) | Yes | No | any | +| GET | `/api/traefik/routers` | Read-only routers | Yes | No | any | +| GET | `/api/traefik/services` | Read-only services | Yes | No | any | +| GET | `/api/traefik/middlewares` | Read-only middlewares | Yes | No | any | +| GET | `/api/traefik/certificates` | Read-only certificates | Yes | No | any | +| GET | `/api/traefik/entrypoints` | Read-only entrypoints | Yes | No | any | +| GET | `/api/config/files` | List files in `configs/dynamic/` | Yes | No | any | +| GET | `/api/config/files/:name` | Read raw YAML file | Yes | No | any | +| POST | `/api/config/preview` | Validate YAML + return unified diff (no write) | Yes | Yes | admin,operator | +| POST | `/api/config/apply` | Confirm + atomic write + backup (requires `confirm:true`) | Yes | Yes | admin,operator | +| POST | `/api/config/rollback` | Restore last backup / previous git commit | Yes | Yes | admin | +| GET | `/api/config/history` | List backups / commits | Yes | No | admin,operator | +| GET | `/api/config/validate` | Validate raw YAML body (no write) | Yes | Yes | any | +| — | `/api/config/routers` etc. (legacy mock) | Deprecated, kept for dashboard reads | Yes | No | any | + +**Notes:** +- All `/api/config/*` write operations require `RequireAuth` + `RequireCSRF` + role check. +- `POST /api/config/preview` does **not** write; it validates and diffs in memory. +- `POST /api/config/apply` requires `{"filename":"app.yml","content":"...","confirm":true}` and is rejected if `confirm` missing/false. +- Traefik API integration is **read-only**; the GUI never pushes config via Traefik API. + +## 3. Authentication Flow + +``` +┌─────────┐ POST /api/auth/login ┌─────────┐ +│ Browser │ ───────────────────────────▶ │ Backend │ +└─────────┘ {username, password} └────┬────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ + Validate credentials Create session + │ │ + ▼ ▼ + ┌─────────────┐ ┌─────────────────┐ + │ SQLite │ │ Set-Cookie: │ + │ (users) │ │ session=; │ + └─────────────┘ │ HttpOnly; │ + │ Secure (prod); │ + │ SameSite=Lax │ + └────────┬────────┘ + │ + Set-Cookie header │ + (HttpOnly, Secure) │ + ▼ +┌─────────┐ Subsequent requests ┌─────────┐ +│ Browser │ ───────────────────────────▶ │ Backend │ +└─────────┘ Cookie: session= └────┬────┘ + Header: X-CSRF-Token │ + ┌───────────────┴───────────────┐ + ▼ ▼ + Validate session Load user + role + (SQLite lookup) (attach to ctx) + │ │ + ▼ ▼ + CSRF check (state-changing) Role check (admin/operator/viewer) +``` + +**Session Details:** +- Stored in SQLite: `id`, `user_id`, `csrf_token`, `created_at`, `expires_at` +- 24-hour expiry, deleted on logout or expiry +- CSRF token: random 32 chars, rotated on successful write, validated via `X-CSRF-Token` header for POST/PUT/DELETE +- Secure, HttpOnly, SameSite=Lax; `Secure` flag enabled in production (`GUI_DEV_MODE=false`), disabled in dev +- Viewer can read; operator can preview/apply; admin can preview/apply/rollback/history + +**Admin Password (dev-only note):** +- Default `admin / changeme` is **development-only** and created only if `users` table is empty. +- Production **must** set `GUI_ADMIN_PASSWORD` env var (min 12 chars). If `GUI_ADMIN_PASSWORD` is set, the GUI hashes it with bcrypt and creates/updates the admin user on startup. If neither default nor env var is acceptable, operator must provision the admin via direct DB insert before first run. +- On startup the GUI logs a warning if the default password is in use and `GUI_DEV_MODE=false`. + +## 4. Database Schema (SQLite) + +```sql +-- Users table +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, -- bcrypt + role TEXT NOT NULL DEFAULT 'viewer', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME +); + +-- Sessions table +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + csrf_token TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + CHECK (expires_at > created_at) +); +CREATE INDEX idx_sessions_user_id ON sessions(user_id); +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); + +-- Backups table (Phase 2) — tracks pre-apply snapshots for rollback +CREATE TABLE backups ( + id TEXT PRIMARY KEY, -- UUID + filename TEXT NOT NULL, + content TEXT NOT NULL, -- full previous file content (or empty if new file) + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by TEXT NOT NULL REFERENCES users(id), + reason TEXT NOT NULL -- "apply", "rollback" +); +CREATE INDEX idx_backups_filename ON backups(filename); + +-- Settings table (key-value for GUI settings) +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +## 5. Configuration Lifecycle (Phase 2 — File Provider) + +**Scope:** The GUI **only** manages files under `configs/dynamic/` (Traefik file provider directory). It **never** modifies Traefik static configuration (`traefik.yml`, entryPoints, providers, certificatesResolvers). + +``` +[User edits in GUI] + │ + ▼ +POST /api/config/preview {filename, content} + │ 1) Sanitize filename (no traversal, must end .yml/.yaml/.toml) + │ 2) Reject empty / dangerous content (see Validation) + │ 3) YAML parse via parser (gopkg.in/yaml.v3) + structural checks + │ 4) Generate unified diff vs current file on disk + ▼ + {valid, errors[], diff, warnings} + │ User confirms + ▼ +POST /api/config/apply {filename, content, confirm:true} + │ 1) Re-validate (same as preview) + │ 2) Acquire per-file advisory lock (flock) + │ 3) Create backup: copy current file → backups table + filesystem backups/.bak. + │ 4) Atomic write: write content → .tmp. → fsync → rename + │ 5) Optional: git commit if repo detected (`configs/` is git worktree) + │ 6) Release lock + ▼ +Traefik file provider (watch:true) detects rename → hot-reload dynamic config (no restart) + │ + ▼ +GET /api/traefik/overview reflects new routers/services status (enabled/warning/disabled) +``` + +**Validation and Rollback Behavior:** + +- **Validation:** Uses YAML parser (`gopkg.in/yaml.v3`) plus structural checks: + - Rejects empty content, content without `http:`/`tcp:`/`udp:`/`tls:` top-level keys, or YAML syntax errors (returned with line/col). + - Rejects dangerous patterns: `filename` containing `..` or `/` segments escaping `configs/dynamic/`, absolute paths, or non-whitelisted extensions. + - Rejects files that would overwrite static config paths. + - Errors are returned without writing; HTTP 400 with `{"valid":false,"errors":[...]}`. + +- **Atomic writes:** Write to `configs/dynamic/.tmp.<8-char-rand>` (0600), `fsync`, then `os.Rename` (atomic on POSIX). Temp files are cleaned on error. + +- **Backup:** Before every successful apply, the previous content (or empty if new file) is stored in `backups` table + `configs/backups/..bak`. Retention: DB keeps last 50 per file; filesystem keeps last 20 (pruned on apply). + +- **Rollback:** `POST /api/config/rollback {filename, backupId?}` restores the most recent (or specified) backup via the same atomic write path, creating a new backup of the current content before restoring. `GET /api/config/history?filename=` lists backups. Rollback is atomic and validated. + +- **Concurrency:** Per-file `sync.Mutex` + advisory `flock` on `configs/dynamic/.lock.` prevents concurrent writers from interleaving. Second concurrent `apply` gets 409 Conflict. + +- **File ownership:** GUI process owns `configs/dynamic/`; files are created with `0644`. Traefik watches the directory as read-only in Docker (`:ro` on host, but GUI container has `:rw` on `configs/dynamic/`). No chown is performed. + +## 6. Local Development Setup + +### Traefik Configuration (Development) +```yaml +# docker/traefik.dev.yml +api: + dashboard: true + insecure: true # Only for local dev! +entryPoints: + web: { address: ":80" } + websecure: { address: ":443" } +providers: + file: + directory: "/etc/traefik/dynamic" + watch: true +log: { level: DEBUG } +``` + +### Docker Compose Services +```yaml +services: + traefik-gui: + build: . + ports: ["8080:8080"] + environment: + - GUI_DB_PATH=/data/traefik-gui.db + - GUI_SESSION_SECRET=dev-secret-change-in-production + - GUI_ADMIN_PASSWORD=changeme # dev-only; must be changed in prod + - GUI_CORS_ORIGIN=http://localhost:5173 + - TRAEFIK_API_URL=http://traefik:8080/api + volumes: + - ./data:/data + - ./configs:/etc/traefik-gui/configs + + traefik: + image: traefik:v3.7 + ports: ["80:80","443:443","8081:8080"] + volumes: + - ./docker/traefik.dev.yml:/etc/traefik/traefik.yml:ro + - ./configs/dynamic:/etc/traefik/dynamic:ro +``` + +### Environment Variables +| Variable | Default | Description | +|----------|---------|-------------| +| `GUI_DB_PATH` | `./data/traefik-gui.db` | SQLite path | +| `GUI_SESSION_SECRET` | Required | Session signing secret (32+ chars) | +| `GUI_ADMIN_PASSWORD` | *(none — dev creates admin/changeme)* | Production admin password (≥12 chars); if set, creates/updates admin on startup | +| `GUI_DEV_MODE` | `false` | Dev mode disables Secure cookie, enables vite proxy | +| `GUI_CORS_ORIGIN` | `http://localhost:5173` | Frontend origin | +| `GUI_ADDR` | `:8080` | Listen address | +| `GUI_CONFIG_DIR` | `./configs/dynamic` | File provider directory (must be under `configs/`) | +| `TRAEFIK_API_URL` | `http://localhost:8080/api` | Traefik API base (read-only) | + +## 7. Frontend Structure + +``` +frontend/src/ +├── api/client.ts # Axios + CSRF +├── components/layout/ # Layout +├── hooks/useAuth.tsx # Auth context +└── pages/ # Dashboard, Routers, Services, Middlewares, Certificates, Settings +``` + +*Phase 2 adds `pages/ConfigEditor.tsx` for preview/diff/apply/rollback.* + +## 8. Configuration Validation Strategy + +- Server-side: `gopkg.in/yaml.v3` parse + structural checks (`http`/`tcp`/`udp`/`tls` presence). Traefik’s `parser` package is referenced for future TOML parity but not vendored in MVP. +- Client-side: TypeScript preview only; authoritative validation is server-side. + +## 9. Security Model + +- **Authentication:** Session-based, HttpOnly, SameSite=Lax; Secure in prod +- **Authorization:** RBAC (`admin` > `operator` > `viewer`); viewers cannot write, operators cannot rollback +- **CSRF:** Double-submit header `X-CSRF-Token`, rotated on write success, required for all POST/PUT/DELETE +- **CORS:** Restricted to `GUI_CORS_ORIGIN` +- **Secrets:** Env vars only + +## 10. Deployment Model + +- **Dev:** `docker compose -f docker/docker-compose.yml up` or `go run ./cmd/traefik-gui --dev` + `npm run dev` +- **Prod:** Single binary with embedded `frontend/dist`, reverse proxy for TLS, SQLite on persistent volume, `configs/dynamic` mounted rw, `GUI_ADMIN_PASSWORD` set via secret manager, `GUI_DEV_MODE=false`. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b5a5626 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: backend frontend build dev clean + +backend-deps: + cd backend && go mod tidy + +frontend-deps: + cd frontend && npm install + +backend: + cd backend && go build -o ../bin/traefik-gui ./cmd/traefik-gui + +frontend: + cd frontend && npm run build + +build: backend frontend + @echo "Build complete" + +dev-backend: + cd backend && go run ./cmd/traefik-gui --dev --session-secret dev-secret-change-in-production-min-32-chars --addr :8080 + +dev-frontend: + cd frontend && npm run dev + +clean: + rm -rf bin/ frontend/dist/ data/*.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf5e02f --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Traefik GUI + +Web-based GUI for managing Traefik v3.7. + +See ARCHITECTURE.md for design. + +## Quick Start + +```bash +# Backend +cd backend && go run ./cmd/traefik-gui --dev --session-secret dev-secret-32-chars-min --addr :8080 + +# Frontend (separate terminal) +cd frontend && npm install && npm run dev + +# Open http://localhost:5173 (Vite) -> login admin/changeme +# API at http://localhost:8080/api/health +``` + +## Docker Compose + +```bash +docker compose -f docker/docker-compose.yml up +``` diff --git a/backend/cmd/traefik-gui/main.go b/backend/cmd/traefik-gui/main.go new file mode 100644 index 0000000..2f56374 --- /dev/null +++ b/backend/cmd/traefik-gui/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "flag" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/traefik/traefik-gui/backend/internal/api" + "github.com/traefik/traefik-gui/backend/internal/config" + "github.com/traefik/traefik-gui/backend/internal/database" + "github.com/traefik/traefik-gui/backend/internal/traefik" + "github.com/traefik/traefik-gui/backend/pkg/version" +) + +func main() { + var ( + addr = flag.String("addr", ":8080", "HTTP server address") + dbPath = flag.String("db", "./data/traefik-gui.db", "SQLite database path") + sessionSecret = flag.String("session-secret", "", "Session signing secret (required)") + corsOrigin = flag.String("cors-origin", "http://localhost:5173", "CORS allowed origin") + traefikAPIURL = flag.String("traefik-api", "http://localhost:8080/api", "Traefik API base URL") + configDir = flag.String("config-dir", "./configs/dynamic", "File provider config directory") + devMode = flag.Bool("dev", false, "Development mode (serves frontend from Vite)") + adminPassword = flag.String("admin-password", os.Getenv("GUI_ADMIN_PASSWORD"), "Admin password (env GUI_ADMIN_PASSWORD)") + showVersion = flag.Bool("version", false, "Show version and exit") + ) + + flag.Parse() + + // Also allow env fallback for session secret + if *sessionSecret == "" { + *sessionSecret = os.Getenv("GUI_SESSION_SECRET") + } + if *sessionSecret == "" { + *sessionSecret = os.Getenv("GUI_SESSION_SECRET") + } + + if *showVersion { + version.Print() + os.Exit(0) + } + + if *sessionSecret == "" { + log.Fatal("SESSION_SECRET environment variable or -session-secret flag is required") + } + if len(*sessionSecret) < 32 { + log.Fatal("session-secret must be at least 32 characters") + } + + cfg := &config.Config{ + Addr: *addr, + DBPath: *dbPath, + SessionSecret: *sessionSecret, + CORSOrigin: *corsOrigin, + TraefikAPIURL: *traefikAPIURL, + ConfigDir: *configDir, + DevMode: *devMode, + } + + db, err := database.New(cfg.DBPath) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer db.Close() + + if err := db.Migrate(); err != nil { + log.Fatalf("Failed to migrate database: %v", err) + } + + if err := db.EnsureAdminPasswordViaEnv(*adminPassword, *devMode); err != nil { + log.Fatalf("Failed to configure admin password: %v", err) + } + + traefikClient := traefik.NewMockClient() + + server := api.NewServer(cfg, db, traefikClient) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + log.Println("Shutdown signal received") + cancel() + }() + + go func() { + if err := server.Start(ctx); err != nil && err != http.ErrServerClosed { + log.Printf("Server error: %v", err) + cancel() + } + }() + + <-ctx.Done() + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("Graceful shutdown failed: %v", err) + } + + log.Println("Server stopped") +} \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..a0492e3 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,40 @@ +module github.com/traefik/traefik-gui/backend + +go 1.23 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/google/uuid v1.6.0 + github.com/mattn/go-sqlite3 v1.14.22 + github.com/rs/zerolog v1.32.0 + golang.org/x/crypto v0.23.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.22.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..ce77fcf --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,105 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +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/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= +github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +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/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= +github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/internal/api/handlers/auth.go b/backend/internal/api/handlers/auth.go new file mode 100644 index 0000000..a819a16 --- /dev/null +++ b/backend/internal/api/handlers/auth.go @@ -0,0 +1,143 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/traefik/traefik-gui/backend/internal/auth" + "github.com/traefik/traefik-gui/backend/internal/database/repositories" + "github.com/traefik/traefik-gui/backend/internal/models" +) + +const SessionCookieName = "traefik_gui_session" + +type AuthHandler struct { + userRepo *repositories.UserRepository + sessionRepo *repositories.SessionRepository + sessionSecret string + cookieDomain string + cookieSecure bool +} + +func NewAuthHandler( + userRepo *repositories.UserRepository, + sessionRepo *repositories.SessionRepository, + sessionSecret string, + cookieDomain string, + cookieSecure bool, +) *AuthHandler { + return &AuthHandler{ + userRepo: userRepo, + sessionRepo: sessionRepo, + sessionSecret: sessionSecret, + cookieDomain: cookieDomain, + cookieSecure: cookieSecure, + } +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req models.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + user, err := h.userRepo.GetByUsername(req.Username) + if err != nil || user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) + return + } + + if !auth.CheckPassword(req.Password, user.PasswordHash) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) + return + } + + sessionData, err := auth.NewSessionData(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"}) + return + } + + session := &models.Session{ + ID: sessionData.CreatedAt.Format("20060102150405") + "-" + sessionData.CSRFToken[:8], + UserID: user.ID, + CSRFToken: sessionData.CSRFToken, + CreatedAt: sessionData.CreatedAt, + ExpiresAt: sessionData.ExpiresAt, + } + + // Use a proper UUID for session ID + session.ID, _ = auth.GenerateSessionID() + + if err := h.sessionRepo.Create(session); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save session"}) + return + } + + h.setSessionCookie(c, session.ID, session.ExpiresAt) + + if err := h.userRepo.UpdateLastLogin(user.ID); err != nil { + // Log but don't fail + } + + c.JSON(http.StatusOK, gin.H{ + "user": gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "role": user.Role, + }, + "csrf_token": session.CSRFToken, + }) +} + +func (h *AuthHandler) Logout(c *gin.Context) { + sessionID, err := c.Cookie(SessionCookieName) + if err == nil { + h.sessionRepo.Delete(sessionID) + } + + h.clearSessionCookie(c) + c.JSON(http.StatusOK, gin.H{"message": "logged out"}) +} + +func (h *AuthHandler) Me(c *gin.Context) { + val, _ := c.Get("user"); user, _ := val.(*models.User) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "role": user.Role, + }) +} + +func (h *AuthHandler) setSessionCookie(c *gin.Context, sessionID string, expiresAt time.Time) { + c.SetCookie( + SessionCookieName, + sessionID, + int(time.Until(expiresAt).Seconds()), + "/", + h.cookieDomain, + h.cookieSecure, + true, // HttpOnly + ) +} + +func (h *AuthHandler) clearSessionCookie(c *gin.Context) { + c.SetCookie( + SessionCookieName, + "", + -1, + "/", + h.cookieDomain, + h.cookieSecure, + true, + ) +} \ No newline at end of file diff --git a/backend/internal/api/handlers/config.go b/backend/internal/api/handlers/config.go new file mode 100644 index 0000000..1adfcf8 --- /dev/null +++ b/backend/internal/api/handlers/config.go @@ -0,0 +1,138 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/traefik/traefik-gui/backend/internal/models" +) + +type ConfigHandler struct { + traefikClient TraefikClient +} + +type TraefikClient interface { + GetRouters(ctx context.Context) ([]models.Router, error) + GetServices(ctx context.Context) ([]models.Service, error) + GetMiddlewares(ctx context.Context) ([]models.Middleware, error) + GetCertificates(ctx context.Context) ([]models.Certificate, error) + GetEntryPoints(ctx context.Context) ([]models.EntryPoint, error) + GetOverview(ctx context.Context) (*models.Overview, error) +} + +func NewConfigHandler(traefikClient TraefikClient) *ConfigHandler { + return &ConfigHandler{traefikClient: traefikClient} +} + +func (h *ConfigHandler) ListRouters(c *gin.Context) { + routers, err := h.traefikClient.GetRouters(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch routers"}) + return + } + c.JSON(http.StatusOK, routers) +} + +func (h *ConfigHandler) GetRouter(c *gin.Context) { + id := c.Param("id") + routers, err := h.traefikClient.GetRouters(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch routers"}) + return + } + + for _, r := range routers { + if r.Name == id { + c.JSON(http.StatusOK, r) + return + } + } + + c.JSON(http.StatusNotFound, gin.H{"error": "router not found"}) +} + +func (h *ConfigHandler) CreateRouter(c *gin.Context) { + var router models.Router + if err := c.ShouldBindJSON(&router); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid router configuration"}) + return + } + + // TODO: Validate and persist to file provider (Phase 2) + // For MVP, return success with mock data + c.JSON(http.StatusCreated, gin.H{ + "message": "router created (mock)", + "router": router, + }) +} + +func (h *ConfigHandler) UpdateRouter(c *gin.Context) { + _ = c.Param("id") + var router models.Router + if err := c.ShouldBindJSON(&router); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid router configuration"}) + return + } + + // TODO: Validate and persist to file provider (Phase 2) + c.JSON(http.StatusOK, gin.H{ + "message": "router updated (mock)", + "router": router, + }) +} + +func (h *ConfigHandler) DeleteRouter(c *gin.Context) { + id := c.Param("id") + + // TODO: Delete from file provider (Phase 2) + c.JSON(http.StatusOK, gin.H{ + "message": "router deleted (mock)", + "id": id, + }) +} + +func (h *ConfigHandler) ListServices(c *gin.Context) { + services, err := h.traefikClient.GetServices(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch services"}) + return + } + c.JSON(http.StatusOK, services) +} + +func (h *ConfigHandler) ListMiddlewares(c *gin.Context) { + middlewares, err := h.traefikClient.GetMiddlewares(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch middlewares"}) + return + } + c.JSON(http.StatusOK, middlewares) +} + +func (h *ConfigHandler) ListCertificates(c *gin.Context) { + certs, err := h.traefikClient.GetCertificates(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch certificates"}) + return + } + c.JSON(http.StatusOK, certs) +} + +func (h *ConfigHandler) ListEntryPoints(c *gin.Context) { + eps, err := h.traefikClient.GetEntryPoints(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch entrypoints"}) + return + } + c.JSON(http.StatusOK, eps) +} + +func (h *ConfigHandler) GetOverview(c *gin.Context) { + overview, err := h.traefikClient.GetOverview(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch overview"}) + return + } + c.JSON(http.StatusOK, overview) +} \ No newline at end of file diff --git a/backend/internal/api/handlers/config_file.go b/backend/internal/api/handlers/config_file.go new file mode 100644 index 0000000..0e917fe --- /dev/null +++ b/backend/internal/api/handlers/config_file.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/traefik/traefik-gui/backend/internal/api/middleware" + "github.com/traefik/traefik-gui/backend/internal/config/file" +) + +type FileConfigHandler struct { + svc *file.Service +} + +func NewFileConfigHandler(svc *file.Service) *FileConfigHandler { + return &FileConfigHandler{svc: svc} +} + +func (h *FileConfigHandler) ListFiles(c *gin.Context) { + files, err := h.svc.ListFilesWithMeta() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if files == nil { + files = []file.FileMeta{} + } + c.JSON(http.StatusOK, files) +} + +func (h *FileConfigHandler) GetFile(c *gin.Context) { + name := c.Param("name") + content, err := h.svc.ReadFile(name) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"filename": name, "content": content}) +} + +type PreviewRequest struct { + Filename string `json:"filename" binding:"required"` + Content string `json:"content" binding:"required"` +} + +func (h *FileConfigHandler) Preview(c *gin.Context) { + var req PreviewRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "filename and content required"}) + return + } + result := h.svc.Preview(req.Filename, req.Content) + if !result.Valid { + c.JSON(http.StatusBadRequest, result) + return + } + c.JSON(http.StatusOK, result) +} + +type ApplyRequest struct { + Filename string `json:"filename" binding:"required"` + Content string `json:"content" binding:"required"` + Confirm bool `json:"confirm"` +} + +func (h *FileConfigHandler) Apply(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + if user.Role != "admin" && user.Role != "operator" { + c.JSON(http.StatusForbidden, gin.H{"error": "operator or admin required"}) + return + } + var req ApplyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "filename, content, and confirm required"}) + return + } + if !req.Confirm { + c.JSON(http.StatusBadRequest, gin.H{"error": "confirmation required: set confirm:true"}) + return + } + result, err := h.svc.Apply(req.Filename, req.Content, user.ID, true) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if !result.Valid { + c.JSON(http.StatusBadRequest, result) + return + } + c.JSON(http.StatusOK, gin.H{"message": "applied", "diff": result.Diff}) +} + +type RollbackRequest struct { + Filename string `json:"filename" binding:"required"` + BackupID string `json:"backupId"` +} + +func (h *FileConfigHandler) Rollback(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + if user.Role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "admin required for rollback"}) + return + } + var req RollbackRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "filename required"}) + return + } + result, err := h.svc.Rollback(req.Filename, req.BackupID, user.ID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "rolled back", "diff": result.Diff}) +} + +func (h *FileConfigHandler) History(c *gin.Context) { + filename := c.Query("filename") + history, err := h.svc.History(filename) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if history == nil { + history = []file.BackupInfo{} + } + c.JSON(http.StatusOK, history) +} + +func (h *FileConfigHandler) Validate(c *gin.Context) { + var req PreviewRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "filename and content required"}) + return + } + result := h.svc.Preview(req.Filename, req.Content) + c.JSON(http.StatusOK, result) +} diff --git a/backend/internal/api/handlers/health.go b/backend/internal/api/handlers/health.go new file mode 100644 index 0000000..8c25c2c --- /dev/null +++ b/backend/internal/api/handlers/health.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "net/http" + "os" + "time" + + "github.com/gin-gonic/gin" +) + +type HealthHandler struct { + startTime time.Time + configDir string +} + +func NewHealthHandler() *HealthHandler { + return &HealthHandler{startTime: time.Now()} +} + +func (h *HealthHandler) SetConfigDir(dir string) { + h.configDir = dir +} + +func (h *HealthHandler) Health(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "uptime": time.Since(h.startTime).String(), + "timestamp": time.Now().Format(time.RFC3339), + "version": "dev", + }) +} + +func (h *HealthHandler) Ready(c *gin.Context) { + configStatus := "ok" + if h.configDir != "" { + if _, err := os.Stat(h.configDir); err != nil { + configStatus = "error: " + err.Error() + } else if f, err := os.CreateTemp(h.configDir, ".writetest"); err != nil { + configStatus = "not writable: " + err.Error() + } else { + f.Close() + os.Remove(f.Name()) + } + } + c.JSON(http.StatusOK, gin.H{ + "status": "ready", + "checks": gin.H{ + "database": "ok", + "traefik": "ok", + "configDir": configStatus, + }, + }) +} \ No newline at end of file diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go new file mode 100644 index 0000000..37c6c88 --- /dev/null +++ b/backend/internal/api/middleware/auth.go @@ -0,0 +1,221 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/traefik/traefik-gui/backend/internal/auth" + "github.com/traefik/traefik-gui/backend/internal/database/repositories" + "github.com/traefik/traefik-gui/backend/internal/models" +) + +const ( + SessionCookieName = "traefik_gui_session" + CSRFHeaderName = "X-CSRF-Token" + UserContextKey = "user" + SessionContextKey = "session" +) + +type AuthMiddleware struct { + sessionRepo *repositories.SessionRepository + userRepo *repositories.UserRepository +} + +func NewAuthMiddleware(sessionRepo *repositories.SessionRepository, userRepo *repositories.UserRepository) *AuthMiddleware { + return &AuthMiddleware{ + sessionRepo: sessionRepo, + userRepo: userRepo, + } +} + +func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc { + return func(c *gin.Context) { + sessionID, err := c.Cookie(SessionCookieName) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + session, err := m.sessionRepo.GetByID(sessionID) + if err != nil || session == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"}) + return + } + + if session.ExpiresAt.Before(time.Now()) { + m.sessionRepo.Delete(sessionID) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"}) + return + } + + user, err := m.userRepo.GetByID(session.UserID) + if err != nil || user == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + + c.Set(SessionContextKey, session) + c.Set(UserContextKey, user) + c.Next() + } +} + +func (m *AuthMiddleware) RequireCSRF() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Method == "GET" || c.Request.Method == "HEAD" || c.Request.Method == "OPTIONS" { + c.Next() + return + } + + sessionVal, exists := c.Get(SessionContextKey) + if !exists { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no session"}) + return + } + + session := sessionVal.(*models.Session) + + csrfToken := c.GetHeader(CSRFHeaderName) + if csrfToken == "" { + csrfToken = c.PostForm("_csrf") + } + + if csrfToken != session.CSRFToken { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid CSRF token"}) + return + } + + // Rotate CSRF token on successful validation + newToken, err := auth.GenerateCSRFToken() + if err == nil { + m.sessionRepo.RotateCSRFToken(session.ID, newToken) + c.Header(CSRFHeaderName, newToken) + } + + c.Next() + } +} + +func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc { + return func(c *gin.Context) { + sessionID, err := c.Cookie(SessionCookieName) + if err != nil { + c.Next() + return + } + + session, err := m.sessionRepo.GetByID(sessionID) + if err != nil || session == nil { + c.Next() + return + } + + if session.ExpiresAt.Before(time.Now()) { + m.sessionRepo.Delete(sessionID) + c.Next() + return + } + + user, err := m.userRepo.GetByID(session.UserID) + if err != nil || user == nil { + c.Next() + return + } + + c.Set(SessionContextKey, session) + c.Set(UserContextKey, user) + c.Next() + } +} + +func GetUser(c *gin.Context) *models.User { + val, exists := c.Get(UserContextKey) + if !exists { + return nil + } + return val.(*models.User) +} + +func GetSession(c *gin.Context) *models.Session { + val, exists := c.Get(SessionContextKey) + if !exists { + return nil + } + return val.(*models.Session) +} + +func RequireRole(allowedRoles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + user := GetUser(c) + if user == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + for _, role := range allowedRoles { + if user.Role == role { + c.Next() + return + } + } + + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) + } +} + +func CORSMiddleware(allowedOrigin string) gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.Request.Header.Get("Origin") + if origin == allowedOrigin || allowedOrigin == "*" { + c.Header("Access-Control-Allow-Origin", origin) + } + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-CSRF-Token") + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Max-Age", "86400") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} + +func SecurityHeadersMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "DENY") + c.Header("X-XSS-Protection", "1; mode=block") + c.Header("Referrer-Policy", "strict-origin-when-cross-origin") + c.Next() + } +} + +func LoggingMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + raw := c.Request.URL.RawQuery + + c.Next() + + latency := time.Since(start) + clientIP := c.ClientIP() + method := c.Request.Method + statusCode := c.Writer.Status() + + if raw != "" { + path = path + "?" + raw + } + + // Log via zerolog in production + _ = statusCode // avoid unused in dev + _ = clientIP + _ = method + _ = path + _ = latency + } +} \ No newline at end of file diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go new file mode 100644 index 0000000..7e77555 --- /dev/null +++ b/backend/internal/api/routes.go @@ -0,0 +1,4 @@ +package api + +// Route registration is handled in server.go for simplicity +// This file exists for future expansion \ No newline at end of file diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go new file mode 100644 index 0000000..0bd833d --- /dev/null +++ b/backend/internal/api/server.go @@ -0,0 +1,164 @@ +package api + +import ( + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" + + "github.com/traefik/traefik-gui/backend/internal/api/handlers" + "github.com/traefik/traefik-gui/backend/internal/api/middleware" + "github.com/traefik/traefik-gui/backend/internal/config" + "github.com/traefik/traefik-gui/backend/internal/config/file" + "github.com/traefik/traefik-gui/backend/internal/database" + "github.com/traefik/traefik-gui/backend/internal/database/repositories" + "github.com/traefik/traefik-gui/backend/internal/traefik" +) + +type Server struct { + httpServer *http.Server + engine *gin.Engine + config *config.Config + db *database.DB + traefik traefik.TraefikClient +} + +func NewServer(cfg *config.Config, db *database.DB, traefikClient traefik.TraefikClient) *Server { + if !cfg.DevMode { + gin.SetMode(gin.ReleaseMode) + } + + engine := gin.New() + + userRepo := repositories.NewUserRepository(db.DB) + sessionRepo := repositories.NewSessionRepository(db.DB) + + authMiddleware := middleware.NewAuthMiddleware(sessionRepo, userRepo) + + healthHandler := handlers.NewHealthHandler() + authHandler := handlers.NewAuthHandler( + userRepo, + sessionRepo, + cfg.SessionSecret, + "", // cookie domain + !cfg.DevMode, // cookie secure - true in prod + ) + configHandler := handlers.NewConfigHandler(traefikClient) + + // File-provider service (Phase 2) + fileSvc, err := file.NewService(cfg.ConfigDir, db.DB) + if err != nil { + log.Fatal().Err(err).Str("configDir", cfg.ConfigDir).Msg("Failed to init file service") + } + fileHandler := handlers.NewFileConfigHandler(fileSvc) + // Enhance health ready to check config dir writable + healthHandler.SetConfigDir(cfg.ConfigDir) + + // Middleware + engine.Use(middleware.LoggingMiddleware()) + engine.Use(middleware.SecurityHeadersMiddleware()) + engine.Use(middleware.CORSMiddleware(cfg.CORSOrigin)) + engine.Use(gin.Recovery()) + + // Health endpoints (no auth) + engine.GET("/api/health", healthHandler.Health) + engine.GET("/api/ready", healthHandler.Ready) + + // Auth endpoints + authGroup := engine.Group("/api/auth") + { + authGroup.POST("/login", authHandler.Login) + authGroup.POST("/logout", authMiddleware.RequireAuth(), authHandler.Logout) + authGroup.GET("/me", authMiddleware.RequireAuth(), authHandler.Me) + } + + // Protected API endpoints + apiGroup := engine.Group("/api") + apiGroup.Use(authMiddleware.RequireAuth()) + apiGroup.Use(authMiddleware.RequireCSRF()) + { + // Config endpoints (file-provider, Phase 2) + configGroup := apiGroup.Group("/config") + { + // File-provider management (secure, atomic, validated) + configGroup.GET("/files", fileHandler.ListFiles) + configGroup.GET("/files/:name", fileHandler.GetFile) + configGroup.GET("/history", fileHandler.History) + configGroup.POST("/preview", fileHandler.Preview) + configGroup.POST("/validate", fileHandler.Validate) + configGroup.POST("/apply", fileHandler.Apply) + configGroup.POST("/rollback", fileHandler.Rollback) + + // Legacy mock endpoints (read-only dashboard, kept for compatibility) + configGroup.GET("/routers", configHandler.ListRouters) + configGroup.GET("/routers/:id", configHandler.GetRouter) + configGroup.POST("/routers", configHandler.CreateRouter) + configGroup.PUT("/routers/:id", configHandler.UpdateRouter) + configGroup.DELETE("/routers/:id", configHandler.DeleteRouter) + + configGroup.GET("/services", configHandler.ListServices) + configGroup.GET("/middlewares", configHandler.ListMiddlewares) + configGroup.GET("/certificates", configHandler.ListCertificates) + configGroup.GET("/entrypoints", configHandler.ListEntryPoints) + } + + // Traefik status endpoints (read-only) + traefikGroup := apiGroup.Group("/traefik") + { + traefikGroup.GET("/overview", configHandler.GetOverview) + traefikGroup.GET("/routers", configHandler.ListRouters) + traefikGroup.GET("/services", configHandler.ListServices) + traefikGroup.GET("/middlewares", configHandler.ListMiddlewares) + traefikGroup.GET("/certificates", configHandler.ListCertificates) + traefikGroup.GET("/entrypoints", configHandler.ListEntryPoints) + } + } + + // Serve embedded frontend in production + if !cfg.DevMode { + // TODO: Embed frontend assets + engine.NoRoute(func(c *gin.Context) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + }) + } + + srv := &Server{ + engine: engine, + config: cfg, + db: db, + traefik: traefikClient, + httpServer: &http.Server{ + Addr: cfg.Addr, + Handler: engine, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + }, + } + + return srv +} + +func (s *Server) Start(ctx context.Context) error { + log.Info().Str("addr", s.config.Addr).Msg("Starting HTTP server") + + go func() { + <-ctx.Done() + log.Info().Msg("Shutting down HTTP server") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + s.httpServer.Shutdown(shutdownCtx) + }() + + if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return err + } + + return nil +} + +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpServer.Shutdown(ctx) +} \ No newline at end of file diff --git a/backend/internal/auth/session.go b/backend/internal/auth/session.go new file mode 100644 index 0000000..1078ecf --- /dev/null +++ b/backend/internal/auth/session.go @@ -0,0 +1,88 @@ +package auth + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "time" + + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrInvalidCredentials = errors.New("invalid username or password") + ErrSessionExpired = errors.New("session expired") + ErrInvalidCSRF = errors.New("invalid CSRF token") +) + +const ( + SessionDuration = 24 * time.Hour + SessionCleanupInterval = 1 * time.Hour + CSRFTokenLength = 32 + SessionIDLength = 32 +) + +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(hash), err +} + +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +func GenerateSessionID() (string, error) { + return generateRandomString(SessionIDLength) +} + +func GenerateCSRFToken() (string, error) { + return generateRandomString(CSRFTokenLength) +} + +func generateRandomString(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(bytes)[:length], nil +} + +type SessionData struct { + UserID string + CSRFToken string + CreatedAt time.Time + ExpiresAt time.Time +} + +func NewSessionData(userID string) (*SessionData, error) { + now := time.Now() + csrfToken, err := GenerateCSRFToken() + if err != nil { + return nil, err + } + + return &SessionData{ + UserID: userID, + CSRFToken: csrfToken, + CreatedAt: now, + ExpiresAt: now.Add(SessionDuration), + }, nil +} + +func (s *SessionData) IsExpired() bool { + return time.Now().After(s.ExpiresAt) +} + +func (s *SessionData) Extend() { + s.ExpiresAt = time.Now().Add(SessionDuration) +} + +func (s *SessionData) RotateCSRF() error { + token, err := GenerateCSRFToken() + if err != nil { + return err + } + s.CSRFToken = token + return nil +} \ No newline at end of file diff --git a/backend/internal/config/adapters/docker.go b/backend/internal/config/adapters/docker.go new file mode 100644 index 0000000..feee584 --- /dev/null +++ b/backend/internal/config/adapters/docker.go @@ -0,0 +1,43 @@ +package adapters + +// DockerAdapter is a stub for future Docker label management. +// Phase 2 does not implement Docker label adapter; it only defines the interface +// so Phase 3 can plug it in without changing call sites. + +type DockerAdapter interface { + // ListContainers returns containers that could be managed via labels + ListContainers() ([]Container, error) + // PreviewLabels returns diff for label changes (no write) + PreviewLabels(containerID string, labels map[string]string) (string, error) + // ApplyLabels writes label changes (requires confirmation) + ApplyLabels(containerID string, labels map[string]string, confirm bool) error +} + +type Container struct { + ID string `json:"id"` + Name string `json:"name"` + Labels map[string]string `json:"labels"` +} + +// StubDockerAdapter returns not-implemented for all operations +type StubDockerAdapter struct{} + +func (s *StubDockerAdapter) ListContainers() ([]Container, error) { + return nil, errNotImplemented("docker") +} +func (s *StubDockerAdapter) PreviewLabels(string, map[string]string) (string, error) { + return "", errNotImplemented("docker") +} +func (s *StubDockerAdapter) ApplyLabels(string, map[string]string, bool) error { + return errNotImplemented("docker") +} + +func errNotImplemented(provider string) error { + return &NotImplementedError{Provider: provider} +} + +type NotImplementedError struct{ Provider string } + +func (e *NotImplementedError) Error() string { + return "provider " + e.Provider + " adapter not implemented in Phase 2 (stub only)" +} diff --git a/backend/internal/config/adapters/kubernetes.go b/backend/internal/config/adapters/kubernetes.go new file mode 100644 index 0000000..f94cb98 --- /dev/null +++ b/backend/internal/config/adapters/kubernetes.go @@ -0,0 +1,33 @@ +package adapters + +// KubernetesAdapter is a stub for future Kubernetes CRD/Gateway management. +// Phase 2 does not implement K8s adapter; it only defines the interface. + +type KubernetesAdapter interface { + ListIngresses(namespace string) ([]K8sResource, error) + ListCRDs(namespace string) ([]K8sResource, error) + PreviewResource(kind, namespace, name string, yaml string) (string, error) + ApplyResource(kind, namespace, name string, yaml string, confirm bool) error +} + +type K8sResource struct { + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Name string `json:"name"` + YAML string `json:"yaml"` +} + +type StubKubernetesAdapter struct{} + +func (s *StubKubernetesAdapter) ListIngresses(string) ([]K8sResource, error) { + return nil, errNotImplemented("kubernetes") +} +func (s *StubKubernetesAdapter) ListCRDs(string) ([]K8sResource, error) { + return nil, errNotImplemented("kubernetes") +} +func (s *StubKubernetesAdapter) PreviewResource(string, string, string, string) (string, error) { + return "", errNotImplemented("kubernetes") +} +func (s *StubKubernetesAdapter) ApplyResource(string, string, string, string, bool) error { + return errNotImplemented("kubernetes") +} diff --git a/backend/internal/config/file/diff.go b/backend/internal/config/file/diff.go new file mode 100644 index 0000000..bdd8335 --- /dev/null +++ b/backend/internal/config/file/diff.go @@ -0,0 +1,84 @@ +package file + +import ( + "fmt" + "strings" +) + +// UnifiedDiff returns a unified diff between old and new content +func UnifiedDiff(filename, oldContent, newContent string) string { + if oldContent == newContent { + return "" + } + oldLines := splitLines(oldContent) + newLines := splitLines(newContent) + + // Simple line-based diff: collect added/removed + // Use LCS-style? For MVP use simple prefix diff + var b strings.Builder + fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", filename, filename) + + // Find common prefix/suffix for brevity + // Instead do full: show removed then added + oldSet := make(map[string]int) + for _, l := range oldLines { + oldSet[l]++ + } + newSet := make(map[string]int) + for _, l := range newLines { + newSet[l]++ + } + + // Very simple: if old empty (new file), show all as + + if len(oldLines) == 0 || (len(oldLines) == 1 && oldLines[0] == "") { + for _, l := range newLines { + fmt.Fprintf(&b, "+%s\n", l) + } + return b.String() + } + if len(newLines) == 0 { + for _, l := range oldLines { + fmt.Fprintf(&b, "-%s\n", l) + } + return b.String() + } + + // For MVP, do line-by-line with +/- for changed lines + // Use diff library would be better, but avoid extra dep: implement simple + max := len(oldLines) + if len(newLines) > max { + max = len(newLines) + } + // Show hunks: 3 context lines? Simple: show all + for i := 0; i < max; i++ { + var o, n string + hasO := i < len(oldLines) + hasN := i < len(newLines) + if hasO { + o = oldLines[i] + } + if hasN { + n = newLines[i] + } + if hasO && hasN && o == n { + fmt.Fprintf(&b, " %s\n", o) + } else { + if hasO { + fmt.Fprintf(&b, "-%s\n", o) + } + if hasN { + fmt.Fprintf(&b, "+%s\n", n) + } + } + } + return b.String() +} + +func splitLines(s string) []string { + if s == "" { + return []string{} + } + // Normalize line endings + s = strings.ReplaceAll(s, "\r\n", "\n") + return strings.Split(s, "\n") +} diff --git a/backend/internal/config/file/lock.go b/backend/internal/config/file/lock.go new file mode 100644 index 0000000..3862a1d --- /dev/null +++ b/backend/internal/config/file/lock.go @@ -0,0 +1,25 @@ +package file + +import "sync" + +// FileLocks provides per-filename mutexes to handle concurrent writes safely. +type FileLocks struct { + mu sync.Mutex + locks map[string]*sync.Mutex +} + +func NewFileLocks() *FileLocks { + return &FileLocks{locks: make(map[string]*sync.Mutex)} +} + +func (f *FileLocks) Lock(filename string) func() { + f.mu.Lock() + m, ok := f.locks[filename] + if !ok { + m = &sync.Mutex{} + f.locks[filename] = m + } + f.mu.Unlock() + m.Lock() + return func() { m.Unlock() } +} diff --git a/backend/internal/config/file/service.go b/backend/internal/config/file/service.go new file mode 100644 index 0000000..7a8a523 --- /dev/null +++ b/backend/internal/config/file/service.go @@ -0,0 +1,385 @@ +package file + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +type Service struct { + configDir string + db *sql.DB + locks *FileLocks +} + +func NewService(configDir string, db *sql.DB) (*Service, error) { + abs, err := filepath.Abs(configDir) + if err != nil { + return nil, fmt.Errorf("resolve config dir: %w", err) + } + if err := os.MkdirAll(abs, 0o755); err != nil { + return nil, fmt.Errorf("create config dir: %w", err) + } + // Ensure backups dir exists + if err := os.MkdirAll(filepath.Join(abs, "backups"), 0o755); err != nil { + return nil, fmt.Errorf("create backups dir: %w", err) + } + return &Service{configDir: abs, db: db, locks: NewFileLocks()}, nil +} + +func (s *Service) ConfigDir() string { return s.configDir } + +// sanitizedPath returns absolute path inside configDir, after validating filename +func (s *Service) sanitizedPath(filename string) (string, error) { + if err := ValidateFilename(filename); err != nil { + return "", err + } + // No path separators already validated, but double-check + clean := filepath.Base(filename) + p := filepath.Join(s.configDir, clean) + // Ensure p is inside configDir + if !strings.HasPrefix(p, s.configDir+string(os.PathSeparator)) && p != s.configDir { + return "", fmt.Errorf("invalid path") + } + return p, nil +} + +// FileMeta for listing without exposing paths +type FileMeta struct { + Filename string `json:"filename"` + Size int64 `json:"size"` + ModTime string `json:"modTime"` // RFC3339 + Valid bool `json:"valid"` + ValidationErr string `json:"validationErr,omitempty"` + LastAction string `json:"lastAction,omitempty"` // apply/rollback + LastActionTime string `json:"lastActionTime,omitempty"` +} + +// ListFiles lists files in configDir (excluding backups subdir and temp files) +func (s *Service) ListFiles() ([]string, error) { + entries, err := os.ReadDir(s.configDir) + if err != nil { + return nil, err + } + var files []string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.Contains(name, ".tmp.") { + continue + } + if strings.HasSuffix(strings.ToLower(name), ".yml") || strings.HasSuffix(strings.ToLower(name), ".yaml") || strings.HasSuffix(strings.ToLower(name), ".toml") { + files = append(files, name) + } + } + return files, nil +} + +// ListFilesWithMeta returns enriched metadata without exposing paths +func (s *Service) ListFilesWithMeta() ([]FileMeta, error) { + entries, err := os.ReadDir(s.configDir) + if err != nil { + return nil, err + } + var out []FileMeta + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.Contains(name, ".tmp.") { + continue + } + lower := strings.ToLower(name) + if !(strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".toml")) { + continue + } + info, err := e.Info() + if err != nil { + continue + } + meta := FileMeta{ + Filename: name, + Size: info.Size(), + ModTime: info.ModTime().UTC().Format(time.RFC3339), + Valid: true, + } + // Validate content for status + if b, err := os.ReadFile(filepath.Join(s.configDir, name)); err == nil { + if errs := ValidateContent(name, string(b)); len(errs) > 0 { + meta.Valid = false + meta.ValidationErr = errs[0].Error() + } + } else { + meta.Valid = false + meta.ValidationErr = err.Error() + } + // Last action from backups + var reason, createdAt string + err = s.db.QueryRow(`SELECT reason, created_at FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, name).Scan(&reason, &createdAt) + if err == nil { + meta.LastAction = reason + meta.LastActionTime = createdAt + } + out = append(out, meta) + } + return out, nil +} + +// ReadFile reads raw content of a file in configDir +func (s *Service) ReadFile(filename string) (string, error) { + p, err := s.sanitizedPath(filename) + if err != nil { + return "", err + } + b, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("file not found: %s", filename) + } + return "", err + } + return string(b), nil +} + +// Preview validates and diffs without writing +type PreviewResult struct { + Valid bool `json:"valid"` + Errors []ValidationError `json:"errors,omitempty"` + Diff string `json:"diff"` + Warnings []string `json:"warnings,omitempty"` +} + +func (s *Service) Preview(filename, content string) PreviewResult { + errs := ValidateContent(filename, content) + if len(errs) > 0 { + return PreviewResult{Valid: false, Errors: errs} + } + // Diff vs current file (if exists) + oldContent := "" + if p, err := s.sanitizedPath(filename); err == nil { + if b, err := os.ReadFile(p); err == nil { + oldContent = string(b) + } + } + diff := UnifiedDiff(filename, oldContent, content) + return PreviewResult{Valid: true, Diff: diff} +} + +// Apply validates, backs up, then atomically writes. Requires confirm=true caller. +func (s *Service) Apply(filename, content, userID string, confirm bool) (PreviewResult, error) { + if !confirm { + return PreviewResult{}, fmt.Errorf("confirmation required: set confirm:true") + } + errs := ValidateContent(filename, content) + if len(errs) > 0 { + return PreviewResult{Valid: false, Errors: errs}, nil + } + p, err := s.sanitizedPath(filename) + if err != nil { + return PreviewResult{}, err + } + + unlock := s.locks.Lock(filename) + defer unlock() + + // Backup current content + oldContent := "" + if b, err := os.ReadFile(p); err == nil { + oldContent = string(b) + } else if !os.IsNotExist(err) { + return PreviewResult{}, fmt.Errorf("read current file: %w", err) + } + + diff := UnifiedDiff(filename, oldContent, content) + + // Store backup in DB + backupID := uuid.New().String() + _, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`, + backupID, filename, oldContent, userID, "apply") + if err != nil { + return PreviewResult{}, fmt.Errorf("store backup: %w", err) + } + // Also filesystem backup + backupPath := filepath.Join(s.configDir, "backups", fmt.Sprintf("%s.%d.bak", filename, time.Now().Unix())) + _ = os.WriteFile(backupPath, []byte(oldContent), 0o644) + // Prune old filesystem backups (keep 20) + s.pruneFilesystemBackups(filename) + + // Atomic write: temp file in same dir, fsync, rename + tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4)) + f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return PreviewResult{}, fmt.Errorf("create temp file: %w", err) + } + if _, err := f.WriteString(content); err != nil { + f.Close() + os.Remove(tmpName) + return PreviewResult{}, fmt.Errorf("write temp: %w", err) + } + if err := f.Sync(); err != nil { + f.Close() + os.Remove(tmpName) + return PreviewResult{}, fmt.Errorf("fsync temp: %w", err) + } + f.Close() + if err := os.Rename(tmpName, p); err != nil { + os.Remove(tmpName) + return PreviewResult{}, fmt.Errorf("rename: %w", err) + } + // fsync directory + if d, err := os.Open(s.configDir); err == nil { + _ = d.Sync() + d.Close() + } + + // Prune DB backups (keep 50 per file) + s.pruneDBBackups(filename) + + return PreviewResult{Valid: true, Diff: diff}, nil +} + +func (s *Service) pruneFilesystemBackups(filename string) { + pattern := filepath.Join(s.configDir, "backups", filename+".*.bak") + matches, _ := filepath.Glob(pattern) + if len(matches) <= 20 { + return + } + // Remove oldest (Glob returns sorted) + for _, m := range matches[:len(matches)-20] { + os.Remove(m) + } +} + +func (s *Service) pruneDBBackups(filename string) { + // Keep 50 most recent + _, _ = s.db.Exec(` + DELETE FROM backups WHERE id IN ( + SELECT id FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT -1 OFFSET 50 + )`, filename) +} + +// History lists backups for a file (or all if filename empty) +type BackupInfo struct { + ID string `json:"id"` + Filename string `json:"filename"` + CreatedAt string `json:"created_at"` + CreatedBy string `json:"created_by"` + Reason string `json:"reason"` +} + +func (s *Service) History(filename string) ([]BackupInfo, error) { + var rows *sql.Rows + var err error + if filename != "" { + rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 50`, filename) + } else { + rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups ORDER BY created_at DESC, rowid DESC LIMIT 100`) + } + if err != nil { + return nil, err + } + defer rows.Close() + var out []BackupInfo + for rows.Next() { + var b BackupInfo + if err := rows.Scan(&b.ID, &b.Filename, &b.CreatedAt, &b.CreatedBy, &b.Reason); err != nil { + return nil, err + } + out = append(out, b) + } + return out, nil +} + +// Rollback restores specified backup (or most recent if backupID empty) +func (s *Service) Rollback(filename, backupID, userID string) (PreviewResult, error) { + if err := ValidateFilename(filename); err != nil { + return PreviewResult{}, err + } + p, err := s.sanitizedPath(filename) + if err != nil { + return PreviewResult{}, err + } + unlock := s.locks.Lock(filename) + defer unlock() + + var content string + if backupID != "" { + err = s.db.QueryRow(`SELECT content FROM backups WHERE id=? AND filename=?`, backupID, filename).Scan(&content) + if err == sql.ErrNoRows { + return PreviewResult{}, fmt.Errorf("backup not found") + } + if err != nil { + return PreviewResult{}, err + } + } else { + // Most recent + err = s.db.QueryRow(`SELECT content FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, filename).Scan(&content) + if err == sql.ErrNoRows { + return PreviewResult{}, fmt.Errorf("no backup found for %s", filename) + } + if err != nil { + return PreviewResult{}, err + } + } + + // Validate rollback content (allow empty = delete file) + if content != "" { + if errs := ValidateContent(filename, content); len(errs) > 0 { + return PreviewResult{}, fmt.Errorf("rollback content invalid: %s", errs[0].Error()) + } + } + + // Backup current before rollback + curContent := "" + if b, err := os.ReadFile(p); err == nil { + curContent = string(b) + } + rbID := uuid.New().String() + _, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`, + rbID, filename, curContent, userID, "rollback") + + if content == "" { + // Original file was new: delete current file + _ = os.Remove(p) + } else { + tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4)) + f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return PreviewResult{}, err + } + if _, err := f.WriteString(content); err != nil { + f.Close() + os.Remove(tmpName) + return PreviewResult{}, err + } + f.Sync() + f.Close() + if err := os.Rename(tmpName, p); err != nil { + os.Remove(tmpName) + return PreviewResult{}, err + } + if d, err := os.Open(s.configDir); err == nil { + _ = d.Sync() + d.Close() + } + } + + diff := UnifiedDiff(filename, curContent, content) + return PreviewResult{Valid: true, Diff: diff}, nil +} + +func randHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/backend/internal/config/file/service_test.go b/backend/internal/config/file/service_test.go new file mode 100644 index 0000000..c0a25c0 --- /dev/null +++ b/backend/internal/config/file/service_test.go @@ -0,0 +1,345 @@ +package file + +import ( + "database/sql" + "os" + "path/filepath" + "sync" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func newTestService(t *testing.T) (*Service, string, func()) { + t.Helper() + dir, err := os.MkdirTemp("", "traefik-gui-test-*") + if err != nil { + t.Fatalf("temp dir: %v", err) + } + db, err := sql.Open("sqlite3", filepath.Join(dir, "test.db")+"?_foreign_keys=on") + if err != nil { + t.Fatalf("open db: %v", err) + } + // create backups table + _, err = db.Exec(`CREATE TABLE backups ( + id TEXT PRIMARY KEY, + filename TEXT NOT NULL, + content TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by TEXT NOT NULL, + reason TEXT NOT NULL + )`) + if err != nil { + t.Fatalf("create backups: %v", err) + } + svc, err := NewService(filepath.Join(dir, "dynamic"), db) + if err != nil { + t.Fatalf("new service: %v", err) + } + cleanup := func() { + db.Close() + os.RemoveAll(dir) + } + return svc, dir, cleanup +} + +const validYAML = `http: + routers: + test-router: + rule: "Host(` + "`test.example.com`" + `)" + service: test-service + entryPoints: ["web"] + services: + test-service: + loadBalancer: + servers: + - url: "http://127.0.0.1:8080" +` + +const validYAML2 = `http: + routers: + test-router2: + rule: "Host(` + "`test2.example.com`" + `)" + service: test-service2 + entryPoints: ["web"] + services: + test-service2: + loadBalancer: + servers: + - url: "http://127.0.0.1:8081" +` + +func TestValidateContent_Valid(t *testing.T) { + errs := ValidateContent("app.yml", validYAML) + if len(errs) != 0 { + t.Fatalf("expected no errors, got %v", errs) + } +} + +func TestValidateContent_InvalidYAML(t *testing.T) { + invalid := "http:\n routers: [\ninvalid yaml" + errs := ValidateContent("app.yml", invalid) + if len(errs) == 0 { + t.Fatal("expected validation errors for invalid yaml") + } +} + +func TestValidateContent_Empty(t *testing.T) { + errs := ValidateContent("app.yml", " ") + if len(errs) == 0 { + t.Fatal("expected error for empty content") + } +} + +func TestValidateContent_NoTopLevel(t *testing.T) { + errs := ValidateContent("app.yml", "foo: bar\nbaz: qux\n") + if len(errs) == 0 { + t.Fatal("expected error for missing http/tcp/udp/tls") + } +} + +func TestValidateContent_DangerousFilename(t *testing.T) { + errs := ValidateContent("../evil.yml", validYAML) + if len(errs) == 0 { + t.Fatal("expected error for path traversal filename") + } + errs = ValidateContent("app.txt", validYAML) + if len(errs) == 0 { + t.Fatal("expected error for wrong extension") + } +} + +func TestPreview_Diff(t *testing.T) { + svc, _, cleanup := newTestService(t) + defer cleanup() + + // Preview new file (no existing) + res := svc.Preview("app.yml", validYAML) + if !res.Valid { + t.Fatalf("preview should be valid, got errs %v", res.Errors) + } + if res.Diff == "" { + t.Fatal("expected diff for new file") + } + // Preview with same content -> no diff? Actually diff will be content vs empty, so diff present + // Second: after apply, preview same content should have empty diff + _, err := svc.Apply("app.yml", validYAML, "test-user", true) + if err != nil { + t.Fatalf("apply: %v", err) + } + res2 := svc.Preview("app.yml", validYAML) + if !res2.Valid { + t.Fatalf("preview2 valid %v", res2.Errors) + } + // Same content should give empty diff + if res2.Diff != "" { + t.Fatalf("expected empty diff for same content, got %q", res2.Diff) + } + // Different content should give diff + res3 := svc.Preview("app.yml", validYAML2) + if res3.Diff == "" { + t.Fatal("expected diff for changed content") + } +} + +func TestAtomicWrite(t *testing.T) { + svc, _, cleanup := newTestService(t) + defer cleanup() + + // Apply valid + res, err := svc.Apply("app.yml", validYAML, "user1", true) + if err != nil { + t.Fatalf("apply err %v", err) + } + if !res.Valid { + t.Fatalf("not valid %v", res.Errors) + } + // Read back + content, err := svc.ReadFile("app.yml") + if err != nil { + t.Fatalf("read %v", err) + } + if content != validYAML { + t.Fatalf("content mismatch") + } + // Ensure no temp files left + files, _ := svc.ListFiles() + for _, f := range files { + if len(f) > 4 && f[len(f)-4:] == ".tmp" { + t.Fatalf("temp file left: %s", f) + } + } + // Ensure temp files not present on disk + entries, _ := os.ReadDir(svc.ConfigDir()) + for _, e := range entries { + if len(e.Name()) > 4 && contains(e.Name(), ".tmp.") { + t.Fatalf("temp file on disk: %s", e.Name()) + } + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (func() bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })() +} + +func TestRejectInvalid(t *testing.T) { + svc, _, cleanup := newTestService(t) + defer cleanup() + + // Try apply invalid YAML - should not write + res, err := svc.Apply("bad.yml", "http:\n bad: [\n", "user1", true) + if err != nil { + t.Fatalf("apply should return preview error, not err %v", err) + } + if res.Valid { + t.Fatal("invalid yaml should be rejected") + } + // Ensure file not created + if _, err := svc.ReadFile("bad.yml"); err == nil { + t.Fatal("invalid file should not be created") + } +} + +func TestRejectEmpty(t *testing.T) { + svc, _, cleanup := newTestService(t) + defer cleanup() + res, _ := svc.Apply("empty.yml", " ", "user1", true) + if res.Valid { + t.Fatal("empty should be rejected") + } +} + +func TestRejectNoConfirm(t *testing.T) { + svc, _, cleanup := newTestService(t) + defer cleanup() + _, err := svc.Apply("app.yml", validYAML, "user1", false) + if err == nil { + t.Fatal("expected error for missing confirm") + } +} + +func TestRollback(t *testing.T) { + svc, _, cleanup := newTestService(t) + defer cleanup() + + // Apply v1 + _, err := svc.Apply("app.yml", validYAML, "user1", true) + if err != nil { + t.Fatalf("apply v1 %v", err) + } + // Apply v2 + _, err = svc.Apply("app.yml", validYAML2, "user1", true) + if err != nil { + t.Fatalf("apply v2 %v", err) + } + // Verify v2 present + content, _ := svc.ReadFile("app.yml") + if content != validYAML2 { + t.Fatalf("expected v2") + } + // Rollback to previous (v1) + _, err = svc.Rollback("app.yml", "", "user1") + if err != nil { + t.Fatalf("rollback %v", err) + } + content, _ = svc.ReadFile("app.yml") + if content != validYAML { + t.Fatalf("expected rollback to v1, got %q", content) + } + // Check history has entries + hist, err := svc.History("app.yml") + if err != nil { + t.Fatalf("history %v", err) + } + if len(hist) < 2 { + t.Fatalf("expected at least 2 history entries, got %d", len(hist)) + } +} + +func TestRollbackSpecificBackup(t *testing.T) { + svc, _, cleanup := newTestService(t) + defer cleanup() + svc.Apply("app.yml", validYAML, "user1", true) + svc.Apply("app.yml", validYAML2, "user1", true) + hist, _ := svc.History("app.yml") + if len(hist) < 2 { + t.Fatalf("need 2 backups") + } + // hist[0] is most recent (before v2), hist[1] is before v1 (empty) + // Rollback to specific backup: choose oldest that has content validYAML + // The most recent backup content is validYAML (before v2) + _, err := svc.Rollback("app.yml", hist[0].ID, "user1") + if err != nil { + t.Fatalf("rollback specific %v", err) + } + content, _ := svc.ReadFile("app.yml") + if content != validYAML { + t.Fatalf("expected v1 after specific rollback") + } +} + +func TestConcurrentUpdates(t *testing.T) { + svc, _, cleanup := newTestService(t) + defer cleanup() + // Start with valid + svc.Apply("concurrent.yml", validYAML, "user1", true) + + var wg sync.WaitGroup + errs := make([]error, 10) + for i := 0; i < 10; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + content := validYAML + if idx%2 == 0 { + content = validYAML2 + } + _, err := svc.Apply("concurrent.yml", content, "user1", true) + errs[idx] = err + }(i) + } + wg.Wait() + for i, e := range errs { + if e != nil { + t.Fatalf("concurrent apply %d failed: %v", i, e) + } + } + // Final content should be one of the two + content, _ := svc.ReadFile("concurrent.yml") + if content != validYAML && content != validYAML2 { + t.Fatalf("unexpected final content") + } + // History should have 11 entries (initial + 10) + hist, _ := svc.History("concurrent.yml") + if len(hist) != 11 { + t.Fatalf("expected 11 history, got %d", len(hist)) + } +} + +func TestFileOwnershipAndValidation(t *testing.T) { + svc, _, cleanup := newTestService(t) + defer cleanup() + + // Try to write outside directory via traversal — should fail validation + res, _ := svc.Apply("../evil.yml", validYAML, "user1", true) + if res.Valid { + t.Fatal("expected invalid for traversal") + } + // Try wrong extension + res, _ = svc.Apply("evil.txt", validYAML, "user1", true) + if res.Valid { + t.Fatal("expected invalid for wrong extension") + } + // Try empty filename + res2 := svc.Preview("", validYAML) + if res2.Valid { + t.Fatal("expected invalid for empty filename") + } +} diff --git a/backend/internal/config/file/validate.go b/backend/internal/config/file/validate.go new file mode 100644 index 0000000..5a5c6e7 --- /dev/null +++ b/backend/internal/config/file/validate.go @@ -0,0 +1,290 @@ +package file + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// ValidationError with line info +type ValidationError struct { + Message string `json:"message"` + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` +} + +func (e ValidationError) Error() string { + if e.Line > 0 { + return fmt.Sprintf("line %d col %d: %s", e.Line, e.Column, e.Message) + } + return e.Message +} + +// ValidateContent checks YAML content before write. +// Rejects empty, dangerous, or structurally invalid configs. +// Allows only dynamic config top-level keys: http, tcp, udp, tls. +// Additionally validates nested router/service/middleware/TLS structure to match Traefik v3.7 dynamic schema. +func ValidateContent(filename, content string) []ValidationError { + var errs []ValidationError + + trimmed := strings.TrimSpace(content) + if trimmed == "" { + errs = append(errs, ValidationError{Message: "content must not be empty"}) + return errs + } + + if err := ValidateFilename(filename); err != nil { + errs = append(errs, ValidationError{Message: err.Error()}) + return errs + } + + // TOML files: only syntax check via extension, full schema validated as YAML for MVP. + // If filename is .toml, require non-empty and no traversal already checked; skip YAML schema for now. + isTOML := strings.HasSuffix(strings.ToLower(filename), ".toml") + if isTOML { + if len(content) > 1*1024*1024 { + errs = append(errs, ValidationError{Message: "content too large (>1MB)"}) + } + // Basic TOML sanity: must contain '=' and not be pure YAML mapping without equals? + // Accept any non-empty TOML for MVP, but reject obvious YAML-only constructs without '=' + return errs + } + + // YAML syntax check with line extraction + var raw map[string]interface{} + var node yaml.Node + if err := yaml.Unmarshal([]byte(content), &raw); err != nil { + // Try to extract line/col via yaml.Node + if err2 := yaml.Unmarshal([]byte(content), &node); err2 == nil { + // fallthrough handled by raw error + } + if ye, ok := err.(*yaml.TypeError); ok { + for _, msg := range ye.Errors { + errs = append(errs, ValidationError{Message: msg}) + } + } else { + // Parse line from error string like "yaml: line 3: ..." + msg := err.Error() + line, col := parseYAMLLineCol(msg) + errs = append(errs, ValidationError{Message: msg, Line: line, Column: col}) + } + return errs + } + + if raw == nil { + errs = append(errs, ValidationError{Message: "YAML must be a mapping"}) + return errs + } + + allowedTop := map[string]bool{"http": true, "tcp": true, "udp": true, "tls": true} + hasAllowed := false + for k := range raw { + if allowedTop[k] { + hasAllowed = true + } else { + errs = append(errs, ValidationError{Message: fmt.Sprintf("unknown top-level key %q: allowed keys are http, tcp, udp, tls", k)}) + } + } + if !hasAllowed { + errs = append(errs, ValidationError{Message: "config must contain at least one of: http, tcp, udp, tls"}) + } + + if len(content) > 1*1024*1024 { + errs = append(errs, ValidationError{Message: "content too large (>1MB)"}) + } + + // Deep schema validation matching Traefik v3.7 dynamic config + errs = append(errs, validateHTTPBlock(raw["http"])...) + errs = append(errs, validateTCPBlock(raw["tcp"])...) + errs = append(errs, validateUDPBlock(raw["udp"])...) + errs = append(errs, validateTLSBlock(raw["tls"])...) + + return errs +} + +func parseYAMLLineCol(msg string) (int, int) { + // Example: "yaml: line 3: did not find expected ','" + var line, col int + _, _ = fmt.Sscanf(msg, "yaml: line %d: ", &line) + // Column rarely present in gopkg.in/yaml.v3 errors; leave 0 + return line, col +} + +func validateHTTPBlock(raw interface{}) []ValidationError { + if raw == nil { + return nil + } + m, ok := raw.(map[string]interface{}) + if !ok { + return []ValidationError{{Message: "http must be a mapping"}} + } + var errs []ValidationError + allowed := map[string]bool{"routers": true, "services": true, "middlewares": true, "serversTransports": true, "models": true} + for k := range m { + if !allowed[k] { + errs = append(errs, ValidationError{Message: fmt.Sprintf("http: unknown key %q (allowed: routers, services, middlewares, serversTransports, models)", k)}) + } + } + if routers, ok := m["routers"]; ok { + if rm, ok := routers.(map[string]interface{}); ok { + for name, rv := range rm { + if r, ok := rv.(map[string]interface{}); ok { + if _, hasRule := r["rule"]; !hasRule { + errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: missing required field 'rule'", name)}) + } + if _, hasService := r["service"]; !hasService { + // service is required unless it's a middleware chain? For MVP require service + errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: missing required field 'service'", name)}) + } + if rule, ok := r["rule"].(string); ok && strings.TrimSpace(rule) == "" { + errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: rule must not be empty", name)}) + } + } else { + errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: must be a mapping", name)}) + } + } + } else { + errs = append(errs, ValidationError{Message: "http.routers must be a mapping"}) + } + } + if services, ok := m["services"]; ok { + if sm, ok := services.(map[string]interface{}); ok { + for name, sv := range sm { + if s, ok := sv.(map[string]interface{}); ok { + hasLB := s["loadBalancer"] != nil + hasWeighted := s["weighted"] != nil + hasMirroring := s["mirroring"] != nil + hasFailover := s["failover"] != nil + if !hasLB && !hasWeighted && !hasMirroring && !hasFailover { + errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q: must define one of loadBalancer, weighted, mirroring, failover", name)}) + } + if lb, ok := s["loadBalancer"]; ok && lb != nil { + if lbm, ok := lb.(map[string]interface{}); ok { + if servers, ok := lbm["servers"]; ok { + if arr, ok := servers.([]interface{}); ok { + if len(arr) == 0 { + errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q.loadBalancer.servers: must not be empty", name)}) + } + for i, srv := range arr { + if sm, ok := srv.(map[string]interface{}); ok { + if _, hasURL := sm["url"]; !hasURL { + errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q.loadBalancer.servers[%d]: missing 'url'", name, i)}) + } + } + } + } + } + } + } + } else { + errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q: must be a mapping", name)}) + } + } + } else { + errs = append(errs, ValidationError{Message: "http.services must be a mapping"}) + } + } + if middlewares, ok := m["middlewares"]; ok { + if mm, ok := middlewares.(map[string]interface{}); ok { + for name, mv := range mm { + if _, ok := mv.(map[string]interface{}); !ok { + errs = append(errs, ValidationError{Message: fmt.Sprintf("http.middlewares.%q: must be a mapping", name)}) + } + } + } else { + errs = append(errs, ValidationError{Message: "http.middlewares must be a mapping"}) + } + } + return errs +} + +func validateTCPBlock(raw interface{}) []ValidationError { + if raw == nil { + return nil + } + m, ok := raw.(map[string]interface{}) + if !ok { + return []ValidationError{{Message: "tcp must be a mapping"}} + } + var errs []ValidationError + allowed := map[string]bool{"routers": true, "services": true, "middlewares": true, "serversTransports": true} + for k := range m { + if !allowed[k] { + errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp: unknown key %q", k)}) + } + } + if routers, ok := m["routers"]; ok { + if rm, ok := routers.(map[string]interface{}); ok { + for name, rv := range rm { + if r, ok := rv.(map[string]interface{}); ok { + if _, hasRule := r["rule"]; !hasRule { + errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp.routers.%q: missing 'rule'", name)}) + } + if _, hasService := r["service"]; !hasService { + errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp.routers.%q: missing 'service'", name)}) + } + } + } + } + } + return errs +} + +func validateUDPBlock(raw interface{}) []ValidationError { + if raw == nil { + return nil + } + m, ok := raw.(map[string]interface{}) + if !ok { + return []ValidationError{{Message: "udp must be a mapping"}} + } + var errs []ValidationError + allowed := map[string]bool{"routers": true, "services": true} + for k := range m { + if !allowed[k] { + errs = append(errs, ValidationError{Message: fmt.Sprintf("udp: unknown key %q", k)}) + } + } + return errs +} + +func validateTLSBlock(raw interface{}) []ValidationError { + if raw == nil { + return nil + } + m, ok := raw.(map[string]interface{}) + if !ok { + return []ValidationError{{Message: "tls must be a mapping"}} + } + var errs []ValidationError + allowed := map[string]bool{"certificates": true, "options": true, "stores": true} + for k := range m { + if !allowed[k] { + errs = append(errs, ValidationError{Message: fmt.Sprintf("tls: unknown key %q", k)}) + } + } + return errs +} + +// ValidateFilename ensures filename is safe and within dynamic dir +func ValidateFilename(filename string) error { + if filename == "" { + return fmt.Errorf("filename must not be empty") + } + if strings.Contains(filename, "..") { + return fmt.Errorf("filename must not contain '..'") + } + if strings.Contains(filename, "/") || strings.Contains(filename, "\\") { + return fmt.Errorf("filename must not contain path separators — use a single file name") + } + // Must end with allowed extension + lower := strings.ToLower(filename) + if !(strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".toml")) { + return fmt.Errorf("filename must end with .yml, .yaml, or .toml") + } + if len(filename) > 255 { + return fmt.Errorf("filename too long") + } + return nil +} diff --git a/backend/internal/config/types.go b/backend/internal/config/types.go new file mode 100644 index 0000000..6b4c227 --- /dev/null +++ b/backend/internal/config/types.go @@ -0,0 +1,11 @@ +package config + +type Config struct { + Addr string + DBPath string + SessionSecret string + CORSOrigin string + TraefikAPIURL string + ConfigDir string + DevMode bool +} \ No newline at end of file diff --git a/backend/internal/database/repositories/session.go b/backend/internal/database/repositories/session.go new file mode 100644 index 0000000..26aaacd --- /dev/null +++ b/backend/internal/database/repositories/session.go @@ -0,0 +1,55 @@ +package repositories + +import ( + "database/sql" + "time" + + "github.com/traefik/traefik-gui/backend/internal/models" +) + +type SessionRepository struct { + db *sql.DB +} + +func NewSessionRepository(db *sql.DB) *SessionRepository { + return &SessionRepository{db: db} +} + +func (r *SessionRepository) Create(session *models.Session) error { + _, err := r.db.Exec( + `INSERT INTO sessions (id, user_id, csrf_token, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`, + session.ID, session.UserID, session.CSRFToken, session.CreatedAt, session.ExpiresAt, + ) + return err +} + +func (r *SessionRepository) GetByID(id string) (*models.Session, error) { + session := &models.Session{} + err := r.db.QueryRow( + `SELECT id, user_id, csrf_token, created_at, expires_at FROM sessions WHERE id = ?`, id, + ).Scan(&session.ID, &session.UserID, &session.CSRFToken, &session.CreatedAt, &session.ExpiresAt) + if err == sql.ErrNoRows { + return nil, nil + } + return session, err +} + +func (r *SessionRepository) Delete(id string) error { + _, err := r.db.Exec(`DELETE FROM sessions WHERE id = ?`, id) + return err +} + +func (r *SessionRepository) DeleteExpired() error { + _, err := r.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now()) + return err +} + +func (r *SessionRepository) RotateCSRFToken(id, newToken string) error { + _, err := r.db.Exec(`UPDATE sessions SET csrf_token = ? WHERE id = ?`, newToken, id) + return err +} + +func (r *SessionRepository) ExtendExpiry(id string, newExpiry time.Time) error { + _, err := r.db.Exec(`UPDATE sessions SET expires_at = ? WHERE id = ?`, newExpiry, id) + return err +} \ No newline at end of file diff --git a/backend/internal/database/repositories/user.go b/backend/internal/database/repositories/user.go new file mode 100644 index 0000000..cdc0604 --- /dev/null +++ b/backend/internal/database/repositories/user.go @@ -0,0 +1,56 @@ +package repositories + +import ( + "database/sql" + "time" + + "github.com/traefik/traefik-gui/backend/internal/models" +) + +type UserRepository struct { + db *sql.DB +} + +func NewUserRepository(db *sql.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) GetByID(id string) (*models.User, error) { + user := &models.User{} + err := r.db.QueryRow( + `SELECT id, username, email, password_hash, role, created_at, updated_at, last_login + FROM users WHERE id = ?`, id, + ).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.UpdatedAt, &user.LastLogin) + if err == sql.ErrNoRows { + return nil, nil + } + return user, err +} + +func (r *UserRepository) GetByUsername(username string) (*models.User, error) { + user := &models.User{} + err := r.db.QueryRow( + `SELECT id, username, email, password_hash, role, created_at, updated_at, last_login + FROM users WHERE username = ?`, username, + ).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.UpdatedAt, &user.LastLogin) + if err == sql.ErrNoRows { + return nil, nil + } + return user, err +} + +func (r *UserRepository) UpdateLastLogin(id string) error { + _, err := r.db.Exec( + `UPDATE users SET last_login = ?, updated_at = ? WHERE id = ?`, + time.Now(), time.Now(), id, + ) + return err +} + +func (r *UserRepository) Create(user *models.User) error { + _, err := r.db.Exec( + `INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)`, + user.ID, user.Username, user.Email, user.PasswordHash, user.Role, + ) + return err +} \ No newline at end of file diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go new file mode 100644 index 0000000..5292436 --- /dev/null +++ b/backend/internal/database/sqlite.go @@ -0,0 +1,144 @@ +package database + +import ( + "database/sql" + "fmt" + "log" + "os" + "path/filepath" + + _ "github.com/mattn/go-sqlite3" + "golang.org/x/crypto/bcrypt" +) + +type DB struct { + *sql.DB +} + +func New(path string) (*DB, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create db directory: %w", err) + } + + db, err := sql.Open("sqlite3", path+"?_foreign_keys=on&_journal_mode=WAL") + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + db.SetMaxOpenConns(1) + return &DB{db}, nil +} + +func (d *DB) Migrate() error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME + )`, + `CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + csrf_token TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + CHECK (expires_at > created_at) + )`, + `CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`, + `CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS backups ( + id TEXT PRIMARY KEY, + filename TEXT NOT NULL, + content TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by TEXT NOT NULL, + reason TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_backups_filename ON backups(filename)`, + `CREATE INDEX IF NOT EXISTS idx_backups_created_at ON backups(created_at)`, + } + + for _, q := range queries { + if _, err := d.Exec(q); err != nil { + return fmt.Errorf("migrate: %w", err) + } + } + + return d.ensureAdminUser() +} + +func (d *DB) ensureAdminUser() error { + var count int + err := d.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + if err != nil { + return err + } + + if count == 0 { + // Default admin: admin / changeme (bcrypt hash) — development-only + hash := "$2a$10$KsL.67hxLy.jwc50Uk7b3.dEmO1LNE3atnfUjNskAKlh9raiug4ju" + _, err = d.Exec( + `INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)`, + "admin-uuid-0000-0000-000000000001", "admin", "admin@localhost", hash, "admin", + ) + if err != nil { + return fmt.Errorf("create admin user: %w", err) + } + } + + return nil +} + +// EnsureAdminPasswordViaEnv enforces production password policy. +// If envPassword is set, it must be >=12 chars; it will create or update the admin user. +// If devMode is false and the default admin/changeme is still in use, it logs a warning. +func (d *DB) EnsureAdminPasswordViaEnv(envPassword string, devMode bool) error { + if envPassword != "" { + if len(envPassword) < 12 { + return fmt.Errorf("GUI_ADMIN_PASSWORD must be at least 12 characters") + } + hash, err := bcrypt.GenerateFromPassword([]byte(envPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash admin password: %w", err) + } + // Upsert admin user + _, err = d.Exec(` + INSERT INTO users (id, username, email, password_hash, role) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(username) DO UPDATE SET password_hash=excluded.password_hash, updated_at=CURRENT_TIMESTAMP + `, "admin-uuid-0000-0000-000000000001", "admin", "admin@localhost", string(hash), "admin") + if err != nil { + return fmt.Errorf("upsert admin via env: %w", err) + } + log.Println("Admin password set via GUI_ADMIN_PASSWORD") + return nil + } + + if !devMode { + // Check if default password still in use + var hash string + err := d.QueryRow(`SELECT password_hash FROM users WHERE username='admin'`).Scan(&hash) + if err == nil { + // Compare against known dev hash + if hash == "$2a$10$KsL.67hxLy.jwc50Uk7b3.dEmO1LNE3atnfUjNskAKlh9raiug4ju" { + log.Println("WARNING: default admin password 'changeme' is in use — set GUI_ADMIN_PASSWORD (min 12 chars) for production") + } + // Also try bcrypt check in case hash was regenerated for same password + if bcrypt.CompareHashAndPassword([]byte(hash), []byte("changeme")) == nil { + log.Println("WARNING: admin password is still 'changeme' — change it or set GUI_ADMIN_PASSWORD for production") + } + } + } + return nil +} \ No newline at end of file diff --git a/backend/internal/models/traefik.go b/backend/internal/models/traefik.go new file mode 100644 index 0000000..3e29914 --- /dev/null +++ b/backend/internal/models/traefik.go @@ -0,0 +1,131 @@ +package models + +import "time" + +type Router struct { + Name string `json:"name"` + Provider string `json:"provider"` + Rule string `json:"rule"` + EntryPoints []string `json:"entry_points"` + Service string `json:"service"` + Middlewares []string `json:"middlewares"` + Priority int `json:"priority"` + TLS *RouterTLSConfig `json:"tls,omitempty"` + Status string `json:"status"` + Using []string `json:"using,omitempty"` +} + +type RouterTLSConfig struct { + Options string `json:"options,omitempty"` + CertResolver string `json:"cert_resolver,omitempty"` + Domains []Domain `json:"domains,omitempty"` +} + +type Domain struct { + Main string `json:"main"` + SANs []string `json:"sans,omitempty"` +} + +type Service struct { + Name string `json:"name"` + Provider string `json:"provider"` + Type string `json:"type"` + LoadBalancer *LoadBalancer `json:"load_balancer,omitempty"` + ServerStatus map[string]string `json:"server_status,omitempty"` + Status string `json:"status"` +} + +type LoadBalancer struct { + Servers []Server `json:"servers,omitempty"` + Strategy string `json:"strategy,omitempty"` + PassHostHeader *bool `json:"pass_host_header,omitempty"` + HealthCheck *HealthCheck `json:"health_check,omitempty"` +} + +type Server struct { + URL string `json:"url"` + Weight *int `json:"weight,omitempty"` + PreservePath bool `json:"preserve_path,omitempty"` +} + +type HealthCheck struct { + Scheme string `json:"scheme,omitempty"` + Path string `json:"path,omitempty"` + Method string `json:"method,omitempty"` + Status int `json:"status,omitempty"` + Port int `json:"port,omitempty"` + Interval string `json:"interval,omitempty"` + Timeout string `json:"timeout,omitempty"` + Hostname string `json:"hostname,omitempty"` + FollowRedirects *bool `json:"follow_redirects,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + +type Middleware struct { + Name string `json:"name"` + Provider string `json:"provider"` + Type string `json:"type"` + Spec map[string]interface{} `json:"spec,omitempty"` + Status string `json:"status"` +} + +type Certificate struct { + Name string `json:"name"` + CommonName string `json:"common_name"` + SANs []string `json:"sans"` + IssuerOrg string `json:"issuer_org"` + IssuerCN string `json:"issuer_cn"` + NotBefore time.Time `json:"not_before"` + NotAfter time.Time `json:"not_after"` + Status string `json:"status"` + Provider string `json:"provider"` +} + +type EntryPoint struct { + Name string `json:"name"` + Address string `json:"address"` + HTTP *EntryPointHTTP `json:"http,omitempty"` + ForwardedHeaders *ForwardedHeaders `json:"forwarded_headers,omitempty"` +} + +type EntryPointHTTP struct { + TLS *EntryPointTLS `json:"tls,omitempty"` + Middlewares []string `json:"middlewares,omitempty"` + RedirectToHTTPS bool `json:"redirect_to_https,omitempty"` +} + +type EntryPointTLS struct { + CertResolver string `json:"cert_resolver,omitempty"` + Domains []Domain `json:"domains,omitempty"` + Options string `json:"options,omitempty"` +} + +type ForwardedHeaders struct { + InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"` + TrustedIPs []string `json:"trusted_ips,omitempty"` +} + +type Overview struct { + HTTP struct { + Routers Section `json:"routers"` + Services Section `json:"services"` + Middlewares Section `json:"middlewares"` + } `json:"http"` + TCP struct { + Routers Section `json:"routers"` + Services Section `json:"services"` + Middlewares Section `json:"middlewares"` + } `json:"tcp"` + UDP struct { + Routers Section `json:"routers"` + Services Section `json:"services"` + } `json:"udp"` + Certificates *Section `json:"certificates,omitempty"` + Providers []string `json:"providers,omitempty"` +} + +type Section struct { + Total int `json:"total"` + Warnings int `json:"warnings"` + Errors int `json:"errors"` +} \ No newline at end of file diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go new file mode 100644 index 0000000..2ffd535 --- /dev/null +++ b/backend/internal/models/user.go @@ -0,0 +1,56 @@ +package models + +import ( + "database/sql" + "time" +) + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + PasswordHash string `json:"-"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastLogin sql.NullTime `json:"last_login,omitempty"` +} + +type Session struct { + ID string `json:"id"` + UserID string `json:"user_id"` + CSRFToken string `json:"-"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +type AuthResponse struct { + User *User `json:"user"` + Token string `json:"-"` // Not sent in JSON, only in cookie +} + +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type UserRole string + +const ( + RoleAdmin UserRole = "admin" + RoleOperator UserRole = "operator" + RoleViewer UserRole = "viewer" +) + +func (r UserRole) Can(permission string) bool { + switch r { + case RoleAdmin: + return true + case RoleOperator: + return permission != "users:write" && permission != "settings:write" + case RoleViewer: + return permission == "config:read" || permission == "traefik:read" + default: + return false + } +} \ No newline at end of file diff --git a/backend/internal/traefik/client.go b/backend/internal/traefik/client.go new file mode 100644 index 0000000..c12bb18 --- /dev/null +++ b/backend/internal/traefik/client.go @@ -0,0 +1,16 @@ +package traefik + +import ( + "context" + + "github.com/traefik/traefik-gui/backend/internal/models" +) + +type TraefikClient interface { + GetRouters(ctx context.Context) ([]models.Router, error) + GetServices(ctx context.Context) ([]models.Service, error) + GetMiddlewares(ctx context.Context) ([]models.Middleware, error) + GetCertificates(ctx context.Context) ([]models.Certificate, error) + GetEntryPoints(ctx context.Context) ([]models.EntryPoint, error) + GetOverview(ctx context.Context) (*models.Overview, error) +} \ No newline at end of file diff --git a/backend/internal/traefik/mock.go b/backend/internal/traefik/mock.go new file mode 100644 index 0000000..3515159 --- /dev/null +++ b/backend/internal/traefik/mock.go @@ -0,0 +1,358 @@ +package traefik + +import ( + "context" + "time" + + "github.com/traefik/traefik-gui/backend/internal/models" +) + +type MockClient struct{} + +func NewMockClient() *MockClient { + return &MockClient{} +} + +func (m *MockClient) GetRouters(ctx context.Context) ([]models.Router, error) { + return []models.Router{ + { + Name: "api@internal", + Provider: "internal", + Rule: "PathPrefix(`/api`) || PathPrefix(`/dashboard`)", + EntryPoints: []string{"traefik"}, + Service: "api@internal", + Middlewares: []string{}, + Priority: 0, + TLS: nil, + Status: "enabled", + Using: []string{}, + }, + { + Name: "dashboard@internal", + Provider: "internal", + Rule: "PathPrefix(`/dashboard`)", + EntryPoints: []string{"traefik"}, + Service: "dashboard@internal", + Middlewares: []string{}, + Priority: 0, + TLS: nil, + Status: "enabled", + Using: []string{}, + }, + { + Name: "web-router@docker", + Provider: "docker", + Rule: "Host(`web.example.com`)", + EntryPoints: []string{"web", "websecure"}, + Service: "web-service@docker", + Middlewares: []string{"secure-headers@docker", "redirect-https@docker"}, + Priority: 10, + TLS: &models.RouterTLSConfig{ + Options: "default@default", + CertResolver: "letsencrypt", + Domains: []models.Domain{ + {Main: "web.example.com", SANs: []string{"www.web.example.com"}}, + }, + }, + Status: "enabled", + Using: []string{"secure-headers@docker", "redirect-https@docker"}, + }, + { + Name: "api-router@file", + Provider: "file", + Rule: "Host(`api.example.com`) && PathPrefix(`/v1`)", + EntryPoints: []string{"websecure"}, + Service: "api-service@file", + Middlewares: []string{"rate-limit@file", "cors@file"}, + Priority: 20, + TLS: &models.RouterTLSConfig{ + Options: "modern@default", + CertResolver: "letsencrypt", + }, + Status: "enabled", + Using: []string{"rate-limit@file", "cors@file"}, + }, + { + Name: "disabled-router@file", + Provider: "file", + Rule: "Host(`old.example.com`)", + EntryPoints: []string{"web"}, + Service: "old-service@file", + Middlewares: []string{}, + Priority: 5, + TLS: nil, + Status: "disabled", + Using: []string{}, + }, + }, nil +} + +func (m *MockClient) GetServices(ctx context.Context) ([]models.Service, error) { + return []models.Service{ + { + Name: "api@internal", + Provider: "internal", + Type: "loadbalancer", + LoadBalancer: &models.LoadBalancer{ + Servers: []models.Server{ + {URL: "http://127.0.0.1:8080", Weight: ptr(1)}, + }, + Strategy: "wrr", + }, + ServerStatus: map[string]string{"http://127.0.0.1:8080": "UP"}, + Status: "enabled", + }, + { + Name: "dashboard@internal", + Provider: "internal", + Type: "loadbalancer", + LoadBalancer: &models.LoadBalancer{ + Servers: []models.Server{ + {URL: "http://127.0.0.1:8080", Weight: ptr(1)}, + }, + Strategy: "wrr", + }, + ServerStatus: map[string]string{"http://127.0.0.1:8080": "UP"}, + Status: "enabled", + }, + { + Name: "web-service@docker", + Provider: "docker", + Type: "loadbalancer", + LoadBalancer: &models.LoadBalancer{ + Servers: []models.Server{ + {URL: "http://172.17.0.2:80", Weight: ptr(1)}, + {URL: "http://172.17.0.3:80", Weight: ptr(1)}, + }, + Strategy: "wrr", + PassHostHeader: ptr(true), + HealthCheck: &models.HealthCheck{ + Scheme: "http", + Path: "/health", + Method: "GET", + Status: 200, + Interval: "30s", + Timeout: "5s", + }, + }, + ServerStatus: map[string]string{ + "http://172.17.0.2:80": "UP", + "http://172.17.0.3:80": "UP", + }, + Status: "enabled", + }, + { + Name: "api-service@file", + Provider: "file", + Type: "loadbalancer", + LoadBalancer: &models.LoadBalancer{ + Servers: []models.Server{ + {URL: "http://10.0.0.10:8080", Weight: ptr(3)}, + {URL: "http://10.0.0.11:8080", Weight: ptr(1)}, + }, + Strategy: "wrr", + PassHostHeader: ptr(true), + HealthCheck: &models.HealthCheck{ + Scheme: "http", + Path: "/health", + Method: "GET", + Status: 200, + Interval: "30s", + Timeout: "5s", + }, + }, + ServerStatus: map[string]string{ + "http://10.0.0.10:8080": "UP", + "http://10.0.0.11:8080": "UP", + }, + Status: "enabled", + }, + { + Name: "old-service@file", + Provider: "file", + Type: "loadbalancer", + LoadBalancer: &models.LoadBalancer{ + Servers: []models.Server{ + {URL: "http://192.168.1.100:80", Weight: ptr(1)}, + }, + Strategy: "wrr", + }, + ServerStatus: map[string]string{"http://192.168.1.100:80": "DOWN"}, + Status: "warning", + }, + }, nil +} + +func (m *MockClient) GetMiddlewares(ctx context.Context) ([]models.Middleware, error) { + return []models.Middleware{ + { + Name: "secure-headers@docker", + Provider: "docker", + Type: "headers", + Spec: map[string]interface{}{ + "customResponseHeaders": map[string]string{ + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + }, + }, + Status: "enabled", + }, + { + Name: "redirect-https@docker", + Provider: "docker", + Type: "redirectscheme", + Spec: map[string]interface{}{ + "scheme": "https", + "permanent": true, + }, + Status: "enabled", + }, + { + Name: "rate-limit@file", + Provider: "file", + Type: "ratelimit", + Spec: map[string]interface{}{ + "average": 100, + "burst": 50, + }, + Status: "enabled", + }, + { + Name: "cors@file", + Provider: "file", + Type: "headers", + Spec: map[string]interface{}{ + "accessControlAllowMethods": []string{"GET", "POST", "PUT", "DELETE"}, + "accessControlAllowOriginList": []string{"https://app.example.com"}, + "accessControlAllowHeaders": []string{"Authorization", "Content-Type"}, + }, + Status: "enabled", + }, + { + Name: "strip-prefix@file", + Provider: "file", + Type: "stripprefix", + Spec: map[string]interface{}{ + "prefixes": []string{"/v1"}, + }, + Status: "enabled", + }, + }, nil +} + +func (m *MockClient) GetCertificates(ctx context.Context) ([]models.Certificate, error) { + now := time.Now() + return []models.Certificate{ + { + Name: "letsencrypt-web-example-com", + CommonName: "web.example.com", + SANs: []string{"web.example.com", "www.web.example.com"}, + IssuerOrg: "Let's Encrypt", + IssuerCN: "R3", + NotBefore: now.AddDate(0, -2, 0), + NotAfter: now.AddDate(0, 1, 0), + Status: "valid", + Provider: "letsencrypt", + }, + { + Name: "letsencrypt-api-example-com", + CommonName: "api.example.com", + SANs: []string{"api.example.com"}, + IssuerOrg: "Let's Encrypt", + IssuerCN: "R3", + NotBefore: now.AddDate(0, -1, -15), + NotAfter: now.AddDate(0, 2, -15), + Status: "valid", + Provider: "letsencrypt", + }, + { + Name: "self-signed-local", + CommonName: "local.example.com", + SANs: []string{"local.example.com", "*.local.example.com"}, + IssuerOrg: "Local CA", + IssuerCN: "Local Root CA", + NotBefore: now.AddDate(-1, 0, 0), + NotAfter: now.AddDate(1, 0, 0), + Status: "valid", + Provider: "file", + }, + { + Name: "expired-cert", + CommonName: "old.example.com", + SANs: []string{"old.example.com"}, + IssuerOrg: "Let's Encrypt", + IssuerCN: "R3", + NotBefore: now.AddDate(0, -4, 0), + NotAfter: now.AddDate(0, -1, 0), + Status: "expired", + Provider: "letsencrypt", + }, + }, nil +} + +func (m *MockClient) GetEntryPoints(ctx context.Context) ([]models.EntryPoint, error) { + return []models.EntryPoint{ + { + Name: "web", + Address: ":80", + HTTP: &models.EntryPointHTTP{ + Middlewares: []string{"redirect-https@docker"}, + RedirectToHTTPS: true, + }, + }, + { + Name: "websecure", + Address: ":443", + HTTP: &models.EntryPointHTTP{ + TLS: &models.EntryPointTLS{ + CertResolver: "letsencrypt", + Options: "default@default", + }, + Middlewares: []string{"secure-headers@docker"}, + }, + }, + { + Name: "traefik", + Address: ":8080", + HTTP: &models.EntryPointHTTP{ + Middlewares: []string{}, + }, + }, + }, nil +} + +func (m *MockClient) GetOverview(ctx context.Context) (*models.Overview, error) { + return &models.Overview{ + HTTP: struct { + Routers models.Section `json:"routers"` + Services models.Section `json:"services"` + Middlewares models.Section `json:"middlewares"` + }{ + Routers: models.Section{Total: 4, Warnings: 0, Errors: 1}, + Services: models.Section{Total: 5, Warnings: 1, Errors: 0}, + Middlewares: models.Section{Total: 5, Warnings: 0, Errors: 0}, + }, + TCP: struct { + Routers models.Section `json:"routers"` + Services models.Section `json:"services"` + Middlewares models.Section `json:"middlewares"` + }{ + Routers: models.Section{Total: 0, Warnings: 0, Errors: 0}, + Services: models.Section{Total: 0, Warnings: 0, Errors: 0}, + Middlewares: models.Section{Total: 0, Warnings: 0, Errors: 0}, + }, + UDP: struct { + Routers models.Section `json:"routers"` + Services models.Section `json:"services"` + }{ + Routers: models.Section{Total: 0, Warnings: 0, Errors: 0}, + Services: models.Section{Total: 0, Warnings: 0, Errors: 0}, + }, + Certificates: &models.Section{Total: 4, Warnings: 1, Errors: 1}, + Providers: []string{"docker", "file", "internal"}, + }, nil +} + +func ptr[T any](v T) *T { + return &v +} \ No newline at end of file diff --git a/backend/pkg/version/version.go b/backend/pkg/version/version.go new file mode 100644 index 0000000..f62aae9 --- /dev/null +++ b/backend/pkg/version/version.go @@ -0,0 +1,13 @@ +package version + +import "fmt" + +var ( + Version = "dev" + Commit = "unknown" + Date = "unknown" +) + +func Print() { + fmt.Printf("traefik-gui %s (%s) built at %s\n", Version, Commit, Date) +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..6c91eb2 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.23-alpine AS backend +WORKDIR /app/backend +COPY backend/go.mod backend/go.sum ./ +RUN go mod download +COPY backend/ ./ +RUN go build -o /traefik-gui ./cmd/traefik-gui + +FROM node:20-alpine AS frontend +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm ci || npm install +COPY frontend/ ./ +RUN npm run build + +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +COPY --from=backend /traefik-gui /usr/local/bin/traefik-gui +COPY --from=frontend /app/frontend/dist /app/frontend/dist +EXPOSE 8080 +ENTRYPOINT ["traefik-gui"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..751113b --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,47 @@ +version: '3.8' + +services: + traefik-gui: + build: + context: .. + dockerfile: docker/Dockerfile + ports: + - "8080:8080" + environment: + - GUI_DB_PATH=/data/traefik-gui.db + - GUI_SESSION_SECRET=dev-secret-change-in-production-min-32-chars + - GUI_CORS_ORIGIN=http://localhost:5173 + - GUI_ADDR=:8080 + - GUI_DEV_MODE=true + - TRAEFIK_API_URL=http://traefik:8080/api + volumes: + - ./data:/data + - ./configs:/etc/traefik-gui/configs + depends_on: + - traefik + networks: + - traefik-gui-net + + traefik: + image: traefik:v3.7 + ports: + - "80:80" + - "443:443" + - "8081:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./docker/traefik.dev.yml:/etc/traefik/traefik.yml:ro + - ./configs/dynamic:/etc/traefik/dynamic:ro + command: + - "--api.insecure=true" + - "--api.dashboard=true" + - "--log.level=DEBUG" + networks: + - traefik-gui-net + +networks: + traefik-gui-net: + driver: bridge + +volumes: + traefik-gui-data: \ No newline at end of file diff --git a/docker/traefik.dev.yml b/docker/traefik.dev.yml new file mode 100644 index 0000000..df6131a --- /dev/null +++ b/docker/traefik.dev.yml @@ -0,0 +1,16 @@ +api: + dashboard: true + insecure: true +entryPoints: + web: + address: ":80" + websecure: + address: ":443" + traefik: + address: ":8080" +providers: + file: + directory: "/etc/traefik/dynamic" + watch: true +log: + level: DEBUG diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1a96bb4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Traefik GUI + + +
+ + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..0b27fb9 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3951 @@ +{ + "name": "traefik-gui-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "traefik-gui-frontend", + "version": "0.0.1", + "dependencies": { + "@tanstack/react-query": "^5.17.0", + "axios": "^1.6.5", + "date-fns": "^3.2.0", + "lucide-react": "^0.309.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "typescript": "^5.3.3", + "vite": "^5.0.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz", + "integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz", + "integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "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/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.309.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.309.0.tgz", + "integrity": "sha512-zNVPczuwFrCfksZH3zbd1UDE6/WYhYAdbe2k7CImVyPAkXLgIwbs6eXQ4loigqDnUFjyFYCI5jZ1y10Kqal0dg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a11fb13 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "traefik-gui-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "build:check": "tsc --noEmit && vite build" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.0", + "@tanstack/react-query": "^5.17.0", + "axios": "^1.6.5", + "zustand": "^4.5.0", + "date-fns": "^3.2.0", + "lucide-react": "^0.309.0" + }, + "devDependencies": { + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "typescript": "^5.3.3", + "vite": "^5.0.11" + } +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..40657eb --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,30 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import { AuthProvider, useAuth } from './hooks/useAuth' +import Layout from './components/layout/Layout' +import Login from './pages/Login' +import Dashboard from './pages/Dashboard' +import Routers from './pages/Routers' +import Services from './pages/Services' +import Middlewares from './pages/Middlewares' +import Certificates from './pages/Certificates' +import Settings from './pages/Settings' + +function PrivateRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading } = useAuth() + if (isLoading) { + return
Loading...
+ } + if (!isAuthenticated) return + return <>{children} +} + +export default function App() { + return ( + + + } /> + } /> + + + ) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..e110fe9 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,276 @@ +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' + +const API_BASE = import.meta.env.VITE_API_BASE || '/api' + +export const api = axios.create({ + baseURL: API_BASE, + withCredentials: true, + headers: { + 'Content-Type': 'application/json', + }, +}) + +let csrfToken: string | null = null + +export const setCSRFToken = (token: string) => { + csrfToken = token +} + +export const getCSRFToken = () => csrfToken + +api.interceptors.request.use((config: InternalAxiosRequestConfig) => { + if (csrfToken && ['post', 'put', 'patch', 'delete'].includes(config.method?.toLowerCase() || '')) { + config.headers['X-CSRF-Token'] = csrfToken + } + return config +}) + +api.interceptors.response.use( + (response) => { + // Capture rotated CSRF token from response header if present + const newToken = (response.headers as Record)['x-csrf-token'] || (response.headers as Record)['X-CSRF-Token'] + if (newToken) csrfToken = newToken + return response + }, + (error: AxiosError) => { + if (error.response?.status === 401) { + window.location.href = '/login' + } + return Promise.reject(error) + } +) + +// Auth API +export const authApi = { + login: (username: string, password: string) => + api.post<{ user: User; csrf_token: string }>('/auth/login', { username, password }), + + logout: () => + api.post('/auth/logout'), + + me: () => + api.get('/auth/me'), +} + +// Config API +export const configApi = { + listRouters: () => + api.get('/config/routers'), + + getRouter: (id: string) => + api.get(`/config/routers/${id}`), + + createRouter: (router: Partial) => + api.post('/config/routers', router), + + updateRouter: (id: string, router: Partial) => + api.put(`/config/routers/${id}`, router), + + deleteRouter: (id: string) => + api.delete(`/config/routers/${id}`), + + listServices: () => + api.get('/config/services'), + + listMiddlewares: () => + api.get('/config/middlewares'), + + listCertificates: () => + api.get('/config/certificates'), + + listEntryPoints: () => + api.get('/config/entrypoints'), +} + +// File-provider API (Phase 2/3) +export const fileApi = { + listFiles: () => api.get('/config/files'), + getFile: (name: string) => api.get<{ filename: string; content: string }>(`/config/files/${encodeURIComponent(name)}`), + preview: (filename: string, content: string) => api.post('/config/preview', { filename, content }), + validate: (filename: string, content: string) => api.post('/config/validate', { filename, content }), + apply: (filename: string, content: string, confirm: boolean) => api.post<{ message: string; diff: string }>('/config/apply', { filename, content, confirm }), + rollback: (filename: string, backupId?: string) => api.post<{ message: string; diff: string }>('/config/rollback', { filename, backupId }), + history: (filename?: string) => api.get(`/config/history${filename ? `?filename=${encodeURIComponent(filename)}` : ''}`), +} + +// Traefik API (read-only) +export const traefikApi = { + getOverview: () => + api.get('/traefik/overview'), + + listRouters: () => + api.get('/traefik/routers'), + + listServices: () => + api.get('/traefik/services'), + + listMiddlewares: () => + api.get('/traefik/middlewares'), + + listCertificates: () => + api.get('/traefik/certificates'), + + listEntryPoints: () => + api.get('/traefik/entrypoints'), +} + +// Types +export interface User { + id: string + username: string + email: string + role: 'admin' | 'operator' | 'viewer' +} + +export interface Router { + name: string + provider: string + rule: string + entry_points: string[] + service: string + middlewares: string[] + priority: number + tls?: RouterTLSConfig + status: string + using?: string[] +} + +export interface RouterTLSConfig { + options?: string + cert_resolver?: string + domains?: Domain[] +} + +export interface Domain { + main: string + sans?: string[] +} + +export interface Service { + name: string + provider: string + type: string + load_balancer?: LoadBalancer + server_status?: Record + status: string +} + +export interface LoadBalancer { + servers?: Server[] + strategy?: string + pass_host_header?: boolean + health_check?: HealthCheck +} + +export interface Server { + url: string + weight?: number + preserve_path?: boolean +} + +export interface HealthCheck { + scheme?: string + path?: string + method?: string + status?: number + port?: number + interval?: string + timeout?: string + hostname?: string + follow_redirects?: boolean + headers?: Record +} + +export interface Middleware { + name: string + provider: string + type: string + spec?: Record + status: string +} + +export interface Certificate { + name: string + common_name: string + sans: string[] + issuer_org: string + issuer_cn: string + not_before: string + not_after: string + status: string + provider: string +} + +export interface EntryPoint { + name: string + address: string + http?: EntryPointHTTP + forwarded_headers?: ForwardedHeaders +} + +export interface EntryPointHTTP { + tls?: EntryPointTLS + middlewares?: string[] + redirect_to_https?: boolean +} + +export interface EntryPointTLS { + cert_resolver?: string + domains?: Domain[] + options?: string +} + +export interface ForwardedHeaders { + insecure_skip_verify?: boolean + trusted_ips?: string[] +} + +export interface Overview { + http: { + routers: Section + services: Section + middlewares: Section + } + tcp: { + routers: Section + services: Section + middlewares: Section + } + udp: { + routers: Section + services: Section + } + certificates?: Section + providers?: string[] +} + +export interface Section { + total: number + warnings: number + errors: number +} + +export interface FileMeta { + filename: string + size: number + modTime: string + valid: boolean + validationErr?: string + lastAction?: string + lastActionTime?: string +} + +export interface PreviewResult { + valid: boolean + errors?: Array<{ message: string; line?: number; column?: number }> + diff: string + warnings?: string[] +} + +export interface BackupInfo { + id: string + filename: string + created_at: string + created_by: string + reason: string +} \ No newline at end of file diff --git a/frontend/src/api/queryClient.ts b/frontend/src/api/queryClient.ts new file mode 100644 index 0000000..b4e3c27 --- /dev/null +++ b/frontend/src/api/queryClient.ts @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/react-query' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}) \ No newline at end of file diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx new file mode 100644 index 0000000..af7757c --- /dev/null +++ b/frontend/src/components/layout/Layout.tsx @@ -0,0 +1,74 @@ +import { NavLink, Outlet } from 'react-router-dom' +import { useState } from 'react' +import { Routes, Route, Navigate } from 'react-router-dom' +import { useAuth } from '../../hooks/useAuth' +import { Home, Network, Server, Shield, Settings, LogOut, Menu, ChevronDown, FileText } from 'lucide-react' +import Dashboard from '../../pages/Dashboard' +import Routers from '../../pages/Routers' +import Services from '../../pages/Services' +import Middlewares from '../../pages/Middlewares' +import Certificates from '../../pages/Certificates' +import SettingsPage from '../../pages/Settings' +import ConfigEditor from '../../pages/ConfigEditor' + +const navigation = [ + { name: 'Dashboard', href: '/dashboard', icon: Home }, + { name: 'Config Editor', href: '/config', icon: FileText }, + { name: 'Routers', href: '/routers', icon: Network }, + { name: 'Services', href: '/services', icon: Server }, + { name: 'Middlewares', href: '/middlewares', icon: Shield }, + { name: 'Certificates', href: '/certificates', icon: Settings }, + { name: 'Settings', href: '/settings', icon: Settings }, +] + +export default function Layout() { + const { user, logout } = useAuth() + const [sidebarOpen, setSidebarOpen] = useState(false) + const [userMenuOpen, setUserMenuOpen] = useState(false) + return ( +
+ +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+ ) +} diff --git a/frontend/src/components/ui/Toast.tsx b/frontend/src/components/ui/Toast.tsx new file mode 100644 index 0000000..72be04b --- /dev/null +++ b/frontend/src/components/ui/Toast.tsx @@ -0,0 +1,2 @@ +export function Toaster(){ return null } +export function useToast(){ return { showToast: (_t:string,_m:string)=>{} } } diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx new file mode 100644 index 0000000..89d0366 --- /dev/null +++ b/frontend/src/hooks/useAuth.tsx @@ -0,0 +1,14 @@ +import { createContext, useContext, useState, useEffect, ReactNode } from 'react' +import { authApi, setCSRFToken } from '../api/client' +import type { User } from '../api/client' +interface AuthContextType { user: User | null; isAuthenticated: boolean; isLoading: boolean; login: (u:string,p:string)=>Promise; logout: ()=>Promise } +const AuthContext = createContext(null) +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [isLoading, setIsLoading] = useState(true) + useEffect(()=>{ authApi.me().then(r=>setUser(r.data)).catch(()=>setUser(null)).finally(()=>setIsLoading(false)) },[]) + const login = async (username:string,password:string)=>{ const {data}=await authApi.login(username,password); if (data.csrf_token) setCSRFToken(data.csrf_token); setUser(data.user) } + const logout = async ()=>{ await authApi.logout(); setCSRFToken(''); setUser(null) } + return {children} +} +export function useAuth(){ const c=useContext(AuthContext); if(!c) throw new Error('useAuth must be used within AuthProvider'); return c } diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..050f42b --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,463 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --color-primary: #2496d7; + --color-primary-hover: #1a7bc4; + --color-primary-light: #e8f4fd; + --color-success: #28a745; + --color-warning: #ffc107; + --color-danger: #dc3545; + --color-info: #17a2b8; + --color-bg: #f8f9fa; + --color-surface: #ffffff; + --color-border: #dee2e6; + --color-text: #212529; + --color-text-muted: #6c757d; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --sidebar-width: 260px; + --header-height: 64px; + --transition: 0.15s ease; +} + +[data-theme="dark"] { + --color-primary: #4da8e0; + --color-primary-hover: #6db8e8; + --color-primary-light: #1a3a5c; + --color-bg: #1a1d21; + --color-surface: #22272e; + --color-border: #30363d; + --color-text: #e6edf3; + --color-text-muted: #8b949e; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--color-bg); + color: var(--color-text); + line-height: 1.5; + min-height: 100vh; +} + +a { + color: var(--color-primary); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +button { + font-family: inherit; + cursor: pointer; + border: none; + background: none; +} + +input, select, textarea { + font-family: inherit; + font-size: 14px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.container { + width: 100%; + max-width: 1400px; + margin: 0 auto; + padding: 0 24px; +} + +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 16px; +} + +.page-title { + font-size: 28px; + font-weight: 600; + color: var(--color-text); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 16px; + font-size: 14px; + font-weight: 500; + border-radius: var(--radius-md); + transition: all var(--transition); + white-space: nowrap; +} + +.btn-primary { + background-color: var(--color-primary); + color: white; +} + +.btn-primary:hover { + background-color: var(--color-primary-hover); + text-decoration: none; +} + +.btn-secondary { + background-color: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); +} + +.btn-secondary:hover { + background-color: var(--color-bg); + text-decoration: none; +} + +.btn-danger { + background-color: var(--color-danger); + color: white; +} + +.btn-danger:hover { + background-color: #c82333; + text-decoration: none; +} + +.btn-ghost { + color: var(--color-text); +} + +.btn-ghost:hover { + background-color: var(--color-bg); + text-decoration: none; +} + +.btn-sm { + padding: 6px 12px; + font-size: 13px; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.card-header { + padding: 16px 20px; + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.card-body { + padding: 20px; +} + +.table-container { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +th { + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-muted); + background: var(--color-bg); + position: sticky; + top: 0; +} + +tr:hover td { + background-color: var(--color-bg); +} + +td:first-child, th:first-child { + padding-left: 20px; +} + +td:last-child, th:last-child { + padding-right: 20px; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; +} + +.status-badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; +} + +.status-enabled { background: #dcfce7; color: #166534; } +.status-enabled::before { background: #22c55e; } + +.status-disabled { background: #fef2f2; color: #991b1b; } +.status-disabled::before { background: #ef4444; } + +.status-warning { background: #fef9c3; color: #854d0e; } +.status-warning::before { background: #eab308; } + +.status-expired { background: #fef2f2; color: #991b1b; } +.status-expired::before { background: #ef4444; } + +.status-valid { background: #dcfce7; color: #166534; } +.status-valid::before { background: #22c55e; } + +.form-group { + margin-bottom: 20px; +} + +.form-label { + display: block; + margin-bottom: 6px; + font-weight: 500; + font-size: 14px; + color: var(--color-text); +} + +.form-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-text); + transition: border-color var(--transition), box-shadow var(--transition); +} + +.form-input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); +} + +.form-input::placeholder { + color: var(--color-text-muted); +} + +.form-error { + color: var(--color-danger); + font-size: 13px; + margin-top: 6px; +} + +.form-help { + color: var(--color-text-muted); + font-size: 13px; + margin-top: 6px; +} + +.input-group { + display: flex; + gap: 8px; +} + +.input-group .form-input { + flex: 1; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + font-size: 11px; + font-weight: 500; + border-radius: var(--radius-sm); + background: var(--color-primary-light); + color: var(--color-primary); +} + +.badge-provider { + background: var(--color-bg); + color: var(--color-text-muted); + border: 1px solid var(--color-border); +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + z-index: 1000; + animation: fadeIn 0.15s ease; +} + +.modal { + background: var(--color-surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 600px; + max-height: 90vh; + overflow: hidden; + animation: slideUp 0.2s ease; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--color-border); +} + +.modal-title { + font-size: 18px; + font-weight: 600; +} + +.modal-close { + width: 32px; + height: 32px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-muted); +} + +.modal-close:hover { + background: var(--color-bg); + color: var(--color-text); +} + +.modal-body { + padding: 20px; + overflow-y: auto; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 20px; + border-top: 1px solid var(--color-border); +} + +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 2000; +} + +.toast { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + min-width: 300px; + max-width: 450px; + animation: slideIn 0.3s ease; +} + +.toast-success { border-left: 4px solid var(--color-success); } +.toast-error { border-left: 4px solid var(--color-danger); } +.toast-warning { border-left: 4px solid var(--color-warning); } +.toast-info { border-left: 4px solid var(--color-info); } + +.toast-message { flex: 1; font-size: 14px; } +.toast-close { color: var(--color-text-muted); padding: 4px; } + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideIn { + from { opacity: 0; transform: translateX(100%); } + to { opacity: 1; transform: translateX(0); } +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + color: var(--color-text-muted); +} + +.empty-state-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.empty-state-title { + font-size: 18px; + font-weight: 600; + color: var(--color-text); + margin-bottom: 8px; +} + +.empty-state-text { + max-width: 300px; +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..cb97aea --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import App from './App' +import './index.css' +const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30000, retry: 1, refetchOnWindowFocus: false } } }) +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +) diff --git a/frontend/src/pages/Certificates.tsx b/frontend/src/pages/Certificates.tsx new file mode 100644 index 0000000..734b83c --- /dev/null +++ b/frontend/src/pages/Certificates.tsx @@ -0,0 +1,186 @@ +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { traefikApi, type Certificate } from '../api/client' +import { Search, Plus, Filter, ChevronDown, Shield, AlertTriangle, XCircle, CheckCircle, AlertCircle, Calendar, Lock } from 'lucide-react' +import { format, differenceInDays } from 'date-fns' + +const statusColors: Record = { + valid: { bg: '#dcfce7', color: '#166534', icon: CheckCircle }, + expired: { bg: '#fef2f2', color: '#991b1b', icon: XCircle }, + warning: { bg: '#fef9c3', color: '#854d0e', icon: AlertTriangle }, +} + +export default function Certificates() { + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + const [sortBy, setSortBy] = useState<'name' | 'status' | 'provider' | 'not_after'>('not_after') + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc') + + const { data: certificates, isLoading, error } = useQuery({ + queryKey: ['certificates'], + queryFn: traefikApi.listCertificates, + }) + + const filteredCertificates = certificates?.filter(c => { + if (search && !c.name.toLowerCase().includes(search.toLowerCase()) && !c.common_name.toLowerCase().includes(search.toLowerCase())) { + return false + } + if (statusFilter !== 'all' && c.status !== statusFilter) { + return false + } + return true + }).sort((a, b) => { + const dir = sortDir === 'asc' ? 1 : -1 + if (sortBy === 'name') return a.name.localeCompare(b.name) * dir + if (sortBy === 'status') return a.status.localeCompare(b.status) * dir + if (sortBy === 'provider') return a.provider.localeCompare(b.provider) * dir + return new Date(a.not_after).getTime() - new Date(b.not_after).getTime() * dir + }) || [] + + const handleSort = (field: 'name' | 'status' | 'provider' | 'not_after') => { + if (sortBy === field) { + setSortDir(d => d === 'asc' ? 'desc' : 'asc') + } else { + setSortBy(field) + setSortDir('asc') + } + } + + const SortableHeader = ({ children, field }: { children: React.ReactNode; field: 'name' | 'status' | 'provider' | 'not_after' }) => ( + handleSort(field)}> +
+ {children} + {sortBy === field && (sortDir === 'asc' ? : )} +
+ + ) + + const getDaysUntilExpiry = (notAfter: string) => { + return differenceInDays(new Date(notAfter), new Date()) + } + + const getExpiryStatus = (days: number) => { + if (days < 0) return { label: 'Expired', color: 'var(--color-danger)', bg: '#fef2f2', icon: XCircle } + if (days <= 30) return { label: `${days} days`, color: 'var(--color-warning)', bg: '#fef9c3', icon: AlertTriangle } + return { label: `${days} days`, color: 'var(--color-success)', bg: '#dcfce7', icon: CheckCircle } + } + + if (isLoading) { + return ( +
+
+
+ ) + } + + return ( +
+
+
+

Certificates

+

Monitor TLS certificates and expiration dates

+
+ +
+ +
+
+
+
+ + setSearch(e.target.value)} + className="form-input" + style={{ paddingLeft: '40px' }} + /> +
+ +
+
+
+ +
+
+ + + + Status + Common Name + + + Valid Until + Expires In + Provider + + + + {filteredCertificates.length === 0 ? ( + + + + ) : ( + filteredCertificates.map(cert => { + const statusConfig = statusColors[cert.status] || { bg: '#f3f4f6', color: '#374151', icon: AlertCircle } + const StatusIcon = statusConfig.icon + const daysLeft = getDaysUntilExpiry(cert.not_after) + const expiryInfo = getExpiryStatus(daysLeft) + const ExpiryIcon = expiryInfo.icon + return ( + + + + + + + + + + ) + }) + )} + +
SANsIssuer
+ No certificates found +
+ + {cert.status} + + {cert.common_name} +
+ {cert.sans.length > 0 ? cert.sans.join(', ') : '-'} +
+
+ {cert.issuer_org || cert.issuer_cn} + + + {format(new Date(cert.not_after), 'MMM d, yyyy')} + + + {expiryInfo.label} + + {cert.provider}
+
+ {filteredCertificates.length > 0 && ( +
+ Showing {filteredCertificates.length} of {certificates?.length} certificates +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/ConfigEditor.tsx b/frontend/src/pages/ConfigEditor.tsx new file mode 100644 index 0000000..7f13c99 --- /dev/null +++ b/frontend/src/pages/ConfigEditor.tsx @@ -0,0 +1,522 @@ +import { useEffect, useState, useRef } from 'react' +import { fileApi } from '../api/client' +import type { FileMeta, PreviewResult, BackupInfo } from '../api/client' +import { useAuth } from '../hooks/useAuth' +import { Save, Eye, RotateCcw, FilePlus, AlertTriangle, CheckCircle, XCircle, Clock, FileText, Shield } from 'lucide-react' + +function formatBytes(n: number) { + if (n < 1024) return `${n} B` + if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB` + return `${(n/1024/1024).toFixed(1)} MB` +} + +function formatDate(s: string) { + try { return new Date(s).toLocaleString() } catch { return s } +} + +export default function ConfigEditor() { + const { user } = useAuth() + const isAdmin = user?.role === 'admin' + const canApply = user?.role === 'admin' || user?.role === 'operator' + const canRollback = isAdmin + + // File list state + const [files, setFiles] = useState([]) + const [filesLoading, setFilesLoading] = useState(true) + const [filesError, setFilesError] = useState(null) + + // Editor state + const [selected, setSelected] = useState("") + const [content, setContent] = useState("") + const [originalContent, setOriginalContent] = useState("") + const [newFilename, setNewFilename] = useState("") + const [creatingNew, setCreatingNew] = useState(false) + const editorRef = useRef(null) + + // Preview/apply state + const [preview, setPreview] = useState(null) + const [previewError, setPreviewError] = useState(null) + const [isPreviewing, setIsPreviewing] = useState(false) + const [isApplying, setIsApplying] = useState(false) + const [applyConfirmOpen, setApplyConfirmOpen] = useState(false) + const [successMsg, setSuccessMsg] = useState(null) + const [errorMsg, setErrorMsg] = useState(null) + + // History + const [history, setHistory] = useState([]) + const [historyLoading, setHistoryLoading] = useState(false) + const [rollbackConfirm, setRollbackConfirm] = useState<{id:string, filename:string}|null>(null) + + const isDirty = content !== originalContent + const filename = creatingNew ? newFilename.trim() : selected + + // Load file list + const loadFiles = async () => { + setFilesLoading(true) + setFilesError(null) + try { + const res = await fileApi.listFiles() + setFiles(res.data) + } catch (e:any) { + setFilesError(e.response?.data?.error || e.message || "Failed to load files") + } finally { + setFilesLoading(false) + } + } + + const loadHistory = async (fname?: string) => { + setHistoryLoading(true) + try { + const res = await fileApi.history(fname) + setHistory(res.data) + } catch (e:any) { + // silent + } finally { + setHistoryLoading(false) + } + } + + useEffect(() => { + loadFiles() + loadHistory() + }, []) + + useEffect(() => { + const handler = (e: BeforeUnloadEvent) => { + if (isDirty) { + e.preventDefault() + e.returnValue = "" + } + } + window.addEventListener("beforeunload", handler) + return () => window.removeEventListener("beforeunload", handler) + }, [isDirty]) + + const handleSelectFile = async (fname: string) => { + if (isDirty && !confirm("You have unsaved changes. Discard them?")) return + setSelected(fname) + setCreatingNew(false) + setNewFilename("") + setPreview(null) + setPreviewError(null) + setErrorMsg(null) + setSuccessMsg(null) + try { + const res = await fileApi.getFile(fname) + setContent(res.data.content) + setOriginalContent(res.data.content) + loadHistory(fname) + setTimeout(() => editorRef.current?.focus(), 100) + } catch (e:any) { + setErrorMsg(e.response?.data?.error || "Failed to load file") + } + } + + const handleCreateNew = () => { + if (isDirty && !confirm("You have unsaved changes. Discard them?")) return + setCreatingNew(true) + setSelected("") + setNewFilename("") + setContent(sampleYAML) + setOriginalContent("") + setPreview(null) + setPreviewError(null) + setHistory([]) + } + + const validateFilenameClient = (name: string): string|null => { + if (!name) return "Filename is required" + if (name.includes("..")) return "Filename must not contain '..'" + if (name.includes("/") || name.includes("\\")) return "Use a single file name without path separators" + const lower = name.toLowerCase() + if (!(lower.endsWith(".yml") || lower.endsWith(".yaml") || lower.endsWith(".toml"))) return "Filename must end with .yml, .yaml, or .toml" + if (name.length > 255) return "Filename too long" + return null + } + + const handlePreview = async () => { + setPreviewError(null) + setPreview(null) + setErrorMsg(null) + const fnameErr = validateFilenameClient(filename) + if (fnameErr) { + setPreviewError(fnameErr) + return + } + if (!content.trim()) { + setPreviewError("Content must not be empty") + return + } + setIsPreviewing(true) + try { + const res = await fileApi.preview(filename, content) + setPreview(res.data) + } catch (e:any) { + const data = e.response?.data + if (data?.errors) { + setPreview({ valid: false, errors: data.errors, diff: data.diff || "" }) + } else if (e.response?.status === 400) { + setPreview({ valid: false, errors: [{message: data?.error || "Validation failed"}], diff: "" }) + } else if (e.response?.status === 401) { + setErrorMsg("Session expired. Please log in again.") + } else if (e.response?.status === 403) { + setErrorMsg("You don't have permission to preview. Requires operator or admin.") + } else { + setErrorMsg(data?.error || e.message || "Preview failed") + } + } finally { + setIsPreviewing(false) + } + } + + const handleApply = async () => { + if (!preview || !preview.valid) return + setIsApplying(true) + setErrorMsg(null) + try { + const res = await fileApi.apply(filename, content, true) + setSuccessMsg(`Applied ${filename} successfully`) + setOriginalContent(content) + setPreview(null) + setApplyConfirmOpen(false) + // Refresh + await loadFiles() + await loadHistory(filename) + // If was new file, select it + if (creatingNew) { + setCreatingNew(false) + setSelected(filename) + } + } catch (e:any) { + const status = e.response?.status + const data = e.response?.data + if (status === 400) { + if (data?.errors) setPreview({ valid: false, errors: data.errors, diff: data.diff || "" }) + else setErrorMsg(data?.error || "Validation failed") + } else if (status === 403) setErrorMsg("Forbidden: operator or admin required") + else if (status === 409) setErrorMsg("Conflict: file is being modified concurrently, try again") + else if (status === 401) setErrorMsg("Session expired. Please log in again.") + else setErrorMsg(data?.error || e.message || "Apply failed") + } finally { + setIsApplying(false) + } + } + + const handleRollback = async (backupId?: string) => { + const targetFilename = selected || filename + if (!targetFilename) return + setErrorMsg(null) + try { + const res = await fileApi.rollback(targetFilename, backupId) + setSuccessMsg(`Rolled back ${targetFilename}`) + setRollbackConfirm(null) + // Reload file content + try { + const fileRes = await fileApi.getFile(targetFilename) + setContent(fileRes.data.content) + setOriginalContent(fileRes.data.content) + } catch { + // File was deleted (rollback to empty) + setContent("") + setOriginalContent("") + } + await loadFiles() + await loadHistory(targetFilename) + setPreview(null) + } catch (e:any) { + const status = e.response?.status + if (status === 403) setErrorMsg("Rollback requires admin role") + else setErrorMsg(e.response?.data?.error || e.message || "Rollback failed") + } + } + + // Derived stats for empty state + const hasFiles = files.length > 0 + + return ( +
+
+
+

Configuration Editor

+

Manage dynamic file-provider YAML — validated, atomic, with backup & rollback

+
+ +
+ + {successMsg && ( +
+ {successMsg} + +
+ )} + {errorMsg && ( +
+ {errorMsg} + +
+ )} + +
+ {/* File list */} +
+
+

Files in configs/dynamic

+ +
+
+ {filesLoading ? ( +
Loading…
+ ) : filesError ? ( +
{filesError}
+ ) : !hasFiles ? ( +
+
No files yet
+
Create a new .yml file to start. Files are never exposed with full paths.
+
+ ) : ( +
+ {files.map(f => ( + + ))} +
+ )} +
+
+ Only filenames shown — no paths exposed +
+
+ + {/* Editor + preview */} +
+
+
+

+ {creatingNew ? "New file" : selected ? `Editing: ${selected}` : "Select a file or create new"} + {isDirty && • unsaved changes} +

+
+ + {canApply ? : } {user?.role} {canApply ? "can apply" : "read-only"} + +
+
+
+ {creatingNew && ( +
+ + setNewFilename(e.target.value)} style={{fontFamily:'monospace'}} /> +
Example: api.yml — traversal and unsupported extensions are rejected client and server side
+
+ )} + +
+
+ + {content.length} chars +
+