commit e070a3fb5fcc9a255dd58d821f4d3507f31c2d1d Author: siujamo Date: Tue Jul 7 10:29:28 2026 +0800 feat: initial open-source release of Pipely OTA update server designed for high-concurrency, low-bandwidth deployments. GORM-backed PostgreSQL, JWT auth, device management, artefact versioning, deployment rollout with gated rate-limited downloads, and a React admin panel. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6110bc1 --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +# ───────────────────────────────────────────────────────── +# OTA Manifest Server — Environment Variables +# ───────────────────────────────────────────────────────── +# Copy this file to .env and adjust values for your environment: +# cp .env.example .env +# ───────────────────────────────────────────────────────── + +# ── Server ────────────────────────────────────────────── + +# HTTP listen port. +# Default: 8080 +SERVER_PORT=8080 + +# ── Security ──────────────────────────────────────────── + +# Secret key for signing JWT tokens. Use a long random string in production. +# Generate: openssl rand -hex 32 +# Example: JWT_SECRET=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6 +JWT_SECRET=change-me-in-production + +# JWT token validity period in hours. +# Default: 24 (tokens expire after 1 day) +JWT_EXPIRY_HRS=24 + +# ── Database ──────────────────────────────────────────── + +# PostgreSQL connection string (URL format). +# Format: postgres://:@:/?sslmode=disable +# Default: postgres://postgres:postgres@localhost:5432/manifest?sslmode=disable +# Example: DATABASE_URL=postgres://ota_user:secret@10.0.1.5:5432/manifest?sslmode=disable +DATABASE_URL=postgres://postgres:postgres@localhost:5432/manifest?sslmode=disable + +# ── Storage ───────────────────────────────────────────── + +# Directory where uploaded JAR/artefact files are stored. +# Must be writable by the server process. +# Default: /var/ota/artefacts +# Linux: ARTEFACT_DIR=/var/ota/artefacts +# Windows: ARTEFACT_DIR=D:\ota\artefacts +# macOS: ARTEFACT_DIR=/usr/local/var/ota/artefacts +ARTEFACT_DIR=/var/ota/artefacts + +# ── Download & Bandwidth Control ──────────────────────── + +# Maximum number of simultaneous download streams. +# Under 10 Mbps total bandwidth, each stream is throttled to +# ~BANDWIDTH_BPS / MAX_CONCURRENT_DOWNLOADS bytes/s. +# Default: 50 +# Example: MAX_CONCURRENT_DOWNLOADS=30 (more conservative) +MAX_CONCURRENT_DOWNLOADS=50 + +# Per-connection bandwidth limit in bytes per second. +# The default (209715 ≈ 205 KB/s) keeps 50 concurrent streams +# at roughly 10 Mbps total (50 × 205 KB/s ≈ 10 MB/s). +# Default: 209715 +# Example: BANDWIDTH_BPS=102400 (~100 KB/s per stream, ~5 Mbps total) +BANDWIDTH_BPS=209715 + +# ── Rate Limiting ─────────────────────────────────────── + +# Maximum API requests per second per client IP (token bucket). +# Burst capacity is 2× this value. +# Default: 100 +# Example: RATE_LIMIT_RPS=50 (stricter, for constrained environments) +RATE_LIMIT_RPS=100 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03f77b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Binary +bin/ +*.exe +*.dll +*.so +*.dylib + +# Environment +.env + +# IDE +.idea/ + +# Web +web/node_modules/ +web/dist/ + +# OS +.DS_Store +Thumbs.db + +# Artifacts (local test data) +/var/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d027fc3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# CLAUDE.md - Project Guidelines for OTA Micro-Server + +## Project Overview +This project is a high-concurrency, low-bandwidth OTA (Over-the-Air) management and distribution server built in Go. It is specifically designed to distribute updates to thousands of devices under a highly constrained network pipelining landscape (**10 Mbps bandwidth ceiling**). It manages binary/Jar delta file generation, gating rollouts, and concurrent rate-limiting. + +## Technology Stack +- **Backend Language:** Go (Golang) +- **Database:** PostgreSQL (with migration tool e.g., Flyway/Golang-migrate) +- **Key Algorithms/Protocols:** BSDiff/VCDIFF for delta generation, HTTP Range requests for resumable downloads. +- **Documentation & Code Comments:** Strictly adhere to **British English** spelling conventions (e.g., `colour`, `centre`, `optimise`, `standardisation`). + +--- + +## Build, Test & Run Commands + +### Development +- **Run the server:** `go run cmd/server/main.go` +- **Build the binary:** `go build -o bin/ota-server cmd/server/main.go` +- **Format code:** `go fmt ./...` +- **Lint code:** `golangci-lint run` + +### Testing +- **Run all tests:** `go test ./...` +- **Run with coverage:** `go test -coverprofile=coverage.out ./...` + +--- + +## Code Architecture & Design Principles + +### 1. 10M Bandwidth Strategy (Hard Constraints) +- **Gated Rollout (Token-based):** Devices must acquire a download token before pulling updates. Keep concurrent downloads limited (e.g., max 50 concurrent streams). +- **Rate Limiting:** Implement token bucket or leaky bucket algorithms at the application/middleware layer to throttle download speed per connection. +- **Resumable Downloads:** The download endpoint **must** support HTTP 206 Partial Content and handle `Range` headers properly. + +### 2. Jar/Zip Delta Logic +- **Do not perform direct binary diffs on raw `.jar` files.** +- To generate a delta: + 1. Unzip the old Jar and new Jar into isolated temporary directories. + 2. Compare files sequentially. Identify new, deleted, and modified files. + 3. Apply binary diff (like BSDiff) **only** on modified `.class` or resource files. + 4. Packaging: Zip the modified patches, new files, and a `manifest.json` describing the deletion/replacement steps into a final `.patch` package. + +### 3. Database & Concurrency +- Optimise SQL queries to prevent connection pooling bottlenecks under thousands of periodic heartbeats/checking requests. +- Use explicit transactions when updating device upgrade status (`Checking` -> `Downloading` -> `Verifying` -> `Upgraded`). + +--- + +## Code Style & Conventions +- **Naming:** Idiomatic Go (camelCase for internal variables, PascalCase for exported identifiers). Short but descriptive context names. +- **Error Handling:** Handle all errors explicitly. Wrap errors with meaningful context using `fmt.Errorf("context: %w", err)`. +- **Concurrency:** Ensure goroutines are safe, utilize `sync.Pool` for heavy allocations (like decompression buffers), and use `context.Context` for cancellation propagation. +- **Spelling:** Always double-check spelling in logs, errors, and variable definition to ensure **British English** alignment (e.g., use `optimised_delta_buffer` instead of `optimized_delta_buffer`). diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..32ff1c0 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,187 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + + "onixbyte.com/message-converter-manifest/internal/artefact" + "onixbyte.com/message-converter-manifest/internal/auth" + "onixbyte.com/message-converter-manifest/internal/config" + "onixbyte.com/message-converter-manifest/internal/database" + "onixbyte.com/message-converter-manifest/internal/delta" + "onixbyte.com/message-converter-manifest/internal/deployment" + "onixbyte.com/message-converter-manifest/internal/device" + "onixbyte.com/message-converter-manifest/internal/download" + "onixbyte.com/message-converter-manifest/internal/middleware" +) + +func main() { + cfg := config.Load() + + // Connect to PostgreSQL (auto-creates database if needed) + db, err := database.Connect(cfg.DatabaseURL) + if err != nil { + log.Fatalf("Database connection failed: %v", err) + } + + // Auto-migrate tables + if err := database.AutoMigrate(db); err != nil { + log.Fatalf("Auto-migrate failed: %v", err) + } + + // Initialise auth service and create default admin + authSvc := auth.NewService(db, []byte(cfg.JWTSecret), cfg.JWTExpiryHrs) + if err := authSvc.CreateInitialAdmin("admin", "admin"); err != nil { + log.Printf("Warning: failed to create initial admin: %v", err) + } + log.Println("[main] Default admin user ready (admin / admin)") + + // Ensure artefact storage directory exists + if err := os.MkdirAll(cfg.ArtefactDir, 0755); err != nil { + log.Fatalf("Failed to create artefact directory: %v", err) + } + + // Global rate limiter + tokenBucket := middleware.NewTokenBucket(cfg.RateLimitRPS, cfg.RateLimitRPS*2) + go func() { + ticker := time.NewTicker(10 * time.Minute) + for range ticker.C { + tokenBucket.Cleanup(30 * time.Minute) + } + }() + + // Gated rollout semaphore + downloadGate := middleware.NewGatedSemaphore(cfg.MaxConcurrent) + + // Offline device detection + go func() { + ticker := time.NewTicker(5 * time.Minute) + for range ticker.C { + offline, err := database.GetOfflineDevices(db, 10*time.Minute) + if err != nil { + log.Printf("[health] Offline check failed: %v", err) + continue + } + ids := make([]string, len(offline)) + for i, d := range offline { + ids[i] = d.DeviceID + } + if len(ids) > 0 { + if err := database.MarkDevicesOffline(db, ids); err != nil { + log.Printf("[health] Mark offline failed: %v", err) + } else { + log.Printf("[health] Marked %d devices as offline", len(ids)) + } + } + } + }() + + // Initialise handlers + authHandler := auth.NewHandler(authSvc) + deviceHandler := device.NewHandler(db) + artefactHandler := artefact.NewHandler(db, cfg.ArtefactDir) + deploymentHandler := deployment.NewHandler(db) + deltaHandler := delta.NewHandler(cfg.ArtefactDir) + downloadHandler := download.NewHandler(db, cfg.ArtefactDir, downloadGate, cfg.BandwidthBPS) + + // Setup Gin + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(gin.Recovery()) + + // CORS + r.Use(func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Range") + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + }) + + // -- Public routes (daemon-facing) -- + api := r.Group("/api/v1") + { + api.POST("/devices/register", deviceHandler.Register) + api.POST("/devices/heartbeat", deviceHandler.Heartbeat) + api.GET("/devices/check-update", deviceHandler.CheckUpdate) + } + + // -- Authenticated admin routes -- + admin := api.Group("") + admin.Use(middleware.AuthMiddleware(authSvc)) + { + api.POST("/auth/login", authHandler.Login) + + admin.GET("/devices", deviceHandler.List) + admin.GET("/devices/:deviceId", deviceHandler.Get) + + admin.POST("/artefacts", artefactHandler.Upload) + admin.GET("/artefacts", artefactHandler.List) + admin.GET("/artefacts/:id", artefactHandler.Get) + + admin.POST("/delta/generate", deltaHandler.GenerateDelta) + + admin.POST("/deployments", deploymentHandler.Create) + admin.GET("/deployments", deploymentHandler.List) + admin.GET("/deployments/:id", deploymentHandler.Get) + admin.POST("/deployments/:id/activate", deploymentHandler.Activate) + admin.POST("/deployments/:id/pause", deploymentHandler.Pause) + admin.GET("/deployments/:id/devices", deploymentHandler.ListDevices) + admin.POST("/deployments/:id/rollback", deploymentHandler.Rollback) + + admin.GET("/admin/users", authHandler.ListUsers) + admin.POST("/admin/users", authHandler.CreateUser) + admin.DELETE("/admin/users/:id", authHandler.DeleteUser) + admin.PUT("/admin/users/:id/password", authHandler.ChangePassword) + } + + // -- Download endpoint (rate-limited + gated) -- + packages := api.Group("/packages") + packages.Use(middleware.RateLimitMiddleware(tokenBucket)) + packages.Use(middleware.GatedMiddleware(downloadGate)) + { + packages.GET("/download/:id", downloadHandler.HandleDownload) + } + + // -- Device status update -- + api.POST("/deployments/:id/status", deploymentHandler.UpdateStatus) + + // -- Static admin panel (served at root; API routes take precedence) -- + r.StaticFS("/", http.Dir("./web/dist")) + + // Graceful shutdown + srv := &http.Server{ + Addr: ":" + cfg.ServerPort, + Handler: r, + } + + go func() { + log.Printf("[main] OTA Manifest Server listening on :%s", cfg.ServerPort) + log.Printf("[main] Max concurrent downloads: %d, bandwidth per stream: %d B/s", cfg.MaxConcurrent, cfg.BandwidthBPS) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server error: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("[main] Shutting down...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("Forced shutdown: %v", err) + } + log.Println("[main] Server stopped") +} diff --git a/docs/daemon-api.yaml b/docs/daemon-api.yaml new file mode 100644 index 0000000..b7bad0f --- /dev/null +++ b/docs/daemon-api.yaml @@ -0,0 +1,475 @@ +openapi: "3.0.3" +info: + title: OTA Manifest — Daemon API + description: | + Device-side REST API for the OTA update platform. + Each daemon (`mcd`) calls these endpoints to register, heartbeat, + poll for updates, download artefacts, and report progress. + + **10 Mbps bandwidth design points:** + - Downloads are **rate-limited** (~25 KB/s per connection) and **gated** (max 50 concurrent). + - The download endpoint supports **HTTP Range** (`206 Partial Content`) for resumable transfers. + - `check-update` returns a delta patch when available to minimise bytes on the wire. + version: "1.0.0" + contact: + name: Quanta OTA Team + +servers: + - url: http://localhost:8080/api/v1 + description: Local development server + +tags: + - name: Device + description: Registration, heartbeat, and update checking + - name: Package + description: Artefact download + - name: Deployment + description: Lifecycle status reporting + +# ───────────────────────────────────────────────────────────── +# Paths +# ───────────────────────────────────────────────────────────── +paths: + + # ═══ Device ═══ + + /devices/register: + post: + tags: [Device] + summary: Register or refresh a device + description: | + Called by the daemon on startup. If the `deviceId` already exists + this is an upsert — all fields are updated and `lastHeartbeat` is + refreshed. + operationId: registerDevice + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RegisterDeviceRequest" + example: + deviceId: "mcd-7a3f-001" + ipAddress: "192.168.1.42" + hostname: "gateway-east-01" + os: "Debian 12" + currentVersion: "1.2.0" + groupName: "east-region" + responses: + "200": + description: Device registered or updated + content: + application/json: + schema: + $ref: "#/components/schemas/Device" + example: + id: 1 + deviceId: "mcd-7a3f-001" + ipAddress: "192.168.1.42" + hostname: "gateway-east-01" + os: "Debian 12" + currentVersion: "1.2.0" + status: "online" + groupName: "east-region" + lastHeartbeat: "2026-07-03T10:30:00Z" + createdAt: "2026-07-03T10:30:00Z" + updatedAt: "2026-07-03T10:30:00Z" + "400": + $ref: "#/components/responses/BadRequest" + + /devices/heartbeat: + post: + tags: [Device] + summary: Send periodic heartbeat + description: | + Called by the daemon every 30–60 s. Keeps the device marked `online` + and updates the `currentVersion` so the server always knows which + version each device is running. + operationId: heartbeat + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/HeartbeatRequest" + example: + deviceId: "mcd-7a3f-001" + currentVersion: "1.2.0" + status: "online" + responses: + "200": + description: Heartbeat acknowledged + content: + application/json: + schema: + $ref: "#/components/schemas/StatusResponse" + "400": + $ref: "#/components/responses/BadRequest" + + /devices/check-update: + get: + tags: [Device] + summary: Check whether an update is pending for this device + description: | + Polled by the daemon on a configurable interval (e.g. every 5 min). + When `hasUpdate` is `true` the daemon should proceed to download the + referenced artefact and then POST to `/deployments/{id}/status` as the + install progresses. + operationId: checkUpdate + parameters: + - name: deviceId + in: query + required: true + schema: + type: string + description: The device's unique identifier + example: "mcd-7a3f-001" + responses: + "200": + description: Update status for the device + content: + application/json: + schema: + $ref: "#/components/schemas/CheckUpdateResponse" + examples: + hasUpdate: + summary: Update available + value: + hasUpdate: true + artefactId: 5 + version: "1.3.0" + fileSize: 4194304 + sha256Hash: "a1b2c3d4e5f6..." + downloadUrl: "/api/v1/packages/download/5" + noUpdate: + summary: No update needed + value: + hasUpdate: false + "400": + $ref: "#/components/responses/BadRequest" + + # ═══ Package ═══ + + /packages/download/{id}: + get: + tags: [Package] + summary: Download an artefact (full or delta patch) + description: | + Serves the artefact file with **per-connection bandwidth throttling** + (~25 KB/s). Supports **HTTP Range** requests so interrupted downloads + can be resumed via `Range: bytes=-`. + + **Headers returned:** + - `Content-Disposition: attachment; filename=""` + - `X-Checksum-SHA256: ` — validate the file after download + - `Accept-Ranges: bytes` + - `Content-Length: ` + + **Query parameters** are optional but recommended — they let the + server track each device's download progress. + operationId: downloadPackage + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int64 + description: Artefact ID (from `check-update` response) + example: 5 + - name: deviceId + in: query + required: false + schema: + type: string + description: Device identifier for progress tracking + example: "mcd-7a3f-001" + - name: deploymentId + in: query + required: false + schema: + type: integer + format: int64 + description: Deployment ID for progress tracking + example: 3 + responses: + "200": + description: Full artefact file + headers: + Content-Disposition: + schema: + type: string + description: Attachment filename + X-Checksum-SHA256: + schema: + type: string + description: SHA-256 hex digest of the file + Accept-Ranges: + schema: + type: string + example: bytes + content: + application/octet-stream: + schema: + type: string + format: binary + "206": + description: Partial content (resumed download) + headers: + Content-Range: + schema: + type: string + example: "bytes 1048576-4194303/4194304" + content: + application/octet-stream: + schema: + type: string + format: binary + "404": + $ref: "#/components/responses/NotFound" + "503": + description: Server at download capacity — retry later + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + error: "Server at capacity. Please retry later." + + # ═══ Deployment ═══ + + /deployments/{id}/status: + post: + tags: [Deployment] + summary: Report deployment status for a single device + description: | + Called by the daemon at each lifecycle transition: + + 1. `downloading` — started the download + 2. `downloaded` — download complete (may be omitted for small files) + 3. `verifying` — checking SHA-256 hash + 4. `installing` — applying the update + 5. `completed` — update applied, device running new version + 6. `failed` — unrecoverable error (set `error` field) + + The server tracks these transitions per-device so operators can + monitor rollout progress. + operationId: updateDeploymentStatus + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int64 + description: Deployment ID + example: 3 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeploymentStatusRequest" + examples: + downloading: + value: + deviceId: "mcd-7a3f-001" + status: "downloading" + completed: + value: + deviceId: "mcd-7a3f-001" + status: "completed" + failed: + value: + deviceId: "mcd-7a3f-001" + status: "failed" + error: "SHA256 mismatch: expected a1b2.. got x9y0.." + responses: + "200": + description: Status recorded + content: + application/json: + schema: + $ref: "#/components/schemas/StatusResponse" + "400": + $ref: "#/components/responses/BadRequest" + +# ───────────────────────────────────────────────────────────── +# Component Schemas +# ───────────────────────────────────────────────────────────── +components: + + schemas: + + # -- Requests -- + + RegisterDeviceRequest: + type: object + required: [deviceId] + properties: + deviceId: + type: string + maxLength: 256 + description: Unique device identifier (set by daemon config) + ipAddress: + type: string + description: Device IP at registration time + hostname: + type: string + description: OS hostname + os: + type: string + description: Operating system name and version + currentVersion: + type: string + description: Currently installed client version + groupName: + type: string + description: Logical group for targeted rollouts (defaults to "default") + + HeartbeatRequest: + type: object + required: [deviceId] + properties: + deviceId: + type: string + maxLength: 256 + currentVersion: + type: string + description: Currently running version + status: + type: string + enum: [online, offline, upgrading] + default: online + + DeploymentStatusRequest: + type: object + required: [deviceId, status] + properties: + deviceId: + type: string + description: Device identifier + status: + type: string + enum: + - downloading + - downloaded + - verifying + - installing + - completed + - failed + error: + type: string + description: Error description (only meaningful when status = "failed") + + # -- Responses -- + + Device: + type: object + properties: + id: + type: integer + format: int64 + deviceId: + type: string + ipAddress: + type: string + hostname: + type: string + os: + type: string + currentVersion: + type: string + status: + type: string + enum: [online, offline, upgrading] + groupName: + type: string + lastHeartbeat: + type: string + format: date-time + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + CheckUpdateResponse: + type: object + properties: + hasUpdate: + type: boolean + description: Whether an update is pending + artefactId: + type: integer + format: int64 + description: Artefact ID to pass to the download endpoint + version: + type: string + description: Target version string + fileSize: + type: integer + format: int64 + description: Total file size in bytes + sha256Hash: + type: string + description: Expected SHA-256 hex digest (validate after download) + downloadUrl: + type: string + description: Download path relative to the server + + StatusResponse: + type: object + properties: + status: + type: string + example: "acknowledged" + + ErrorResponse: + type: object + properties: + error: + type: string + description: Human-readable error message + + # -- Reusable responses -- + responses: + BadRequest: + description: Invalid request body or parameters + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + error: "deviceId is required" + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + error: "Artefact not found" + +# ───────────────────────────────────────────────────────────── +# Daemon update flow (non-normative) +# ───────────────────────────────────────────────────────────── +x-daemon-flow: + polling: + interval: "5m (configurable)" + description: | + 1. **Startup** → `POST /devices/register` + 2. **Every N s** → `POST /devices/heartbeat` + 3. **Every 5 min** → `GET /devices/check-update?deviceId=xxx` + 4. **If hasUpdate==true** → + a. `POST /deployments/{id}/status` → `downloading` + b. `GET /packages/download/{artefactId}?deviceId=xxx&deploymentId={id}` + - Use `Range: bytes=-` to resume interrupted transfers + c. Verify SHA-256 hash against `sha256Hash` from step 3 + d. `POST /deployments/{id}/status` → `verifying` + e. `POST /deployments/{id}/status` → `installing` + f. Restart the Java process, confirm health + g. `POST /deployments/{id}/status` → `completed` + h. `POST /devices/heartbeat` with the new `currentVersion` + - **On any failure** → `POST /deployments/{id}/status` → `failed` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7d0dd33 --- /dev/null +++ b/go.mod @@ -0,0 +1,50 @@ +module onixbyte.com/message-converter-manifest + +go 1.26 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/joho/godotenv v1.5.1 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.2 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // 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.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e646878 --- /dev/null +++ b/go.sum @@ -0,0 +1,116 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/artefact/handler.go b/internal/artefact/handler.go new file mode 100644 index 0000000..ed6c148 --- /dev/null +++ b/internal/artefact/handler.go @@ -0,0 +1,137 @@ +package artefact + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "onixbyte.com/message-converter-manifest/internal/database" + "onixbyte.com/message-converter-manifest/internal/model" +) + +type Handler struct { + db *gorm.DB + artefactDir string +} + +func NewHandler(db *gorm.DB, artefactDir string) *Handler { + return &Handler{db: db, artefactDir: artefactDir} +} + +func (h *Handler) Upload(c *gin.Context) { + version := c.PostForm("version") + if version == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "version is required"}) + return + } + + deltaRef := c.PostForm("deltaRef") + metadata := c.PostForm("metadata") + if metadata == "" { + metadata = "{}" + } + + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "file is required: " + err.Error()}) + return + } + defer file.Close() + + destDir := filepath.Join(h.artefactDir, version) + if err := os.MkdirAll(destDir, 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create directory"}) + return + } + + destPath := filepath.Join(destDir, header.Filename) + out, err := os.Create(destPath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create file"}) + return + } + defer out.Close() + + hasher := sha256.New() + writer := io.MultiWriter(out, hasher) + + size, err := io.Copy(writer, file) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"}) + return + } + + sha256Hash := fmt.Sprintf("%x", hasher.Sum(nil)) + + artefact := &model.Artefact{ + Version: version, + FileName: header.Filename, + FilePath: destPath, + FileSize: size, + SHA256Hash: sha256Hash, + DeltaRef: deltaRef, + IsDelta: deltaRef != "", + Metadata: metadata, + } + + if err := database.CreateArtefact(h.db, artefact); err != nil { + os.Remove(destPath) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save artefact: " + err.Error()}) + return + } + + c.JSON(http.StatusCreated, artefact) +} + +func (h *Handler) List(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + artefacts, total, err := database.ListArtefacts(h.db, page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, model.PaginatedResponse{ + Data: artefacts, + Total: total, + Page: page, + PageSize: pageSize, + }) +} + +func (h *Handler) Get(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + artefact, err := database.GetArtefactByID(h.db, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "Artefact not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, artefact) +} diff --git a/internal/auth/admin_handler.go b/internal/auth/admin_handler.go new file mode 100644 index 0000000..803b178 --- /dev/null +++ b/internal/auth/admin_handler.go @@ -0,0 +1,93 @@ +package auth + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "onixbyte.com/message-converter-manifest/internal/model" +) + +// -- Admin user management handlers -- + +type CreateUserRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Role string `json:"role"` +} + +type ChangePasswordRequest struct { + NewPassword string `json:"newPassword" binding:"required"` +} + +// ListUsers handles GET /admin/users. +func (h *Handler) ListUsers(c *gin.Context) { + users, err := h.svc.ListUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if users == nil { + users = []model.AdminUser{} + } + c.JSON(http.StatusOK, gin.H{"data": users}) +} + +// CreateUser handles POST /admin/users. +func (h *Handler) CreateUser(c *gin.Context) { + var req CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.svc.CreateUser(req.Username, req.Password, req.Role) + if err != nil { + if err == ErrUserExists { + c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, user) +} + +// DeleteUser handles DELETE /admin/users/:id. +func (h *Handler) DeleteUser(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + if err := h.svc.DeleteUser(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) +} + +// ChangePassword handles PUT /admin/users/:id/password. +func (h *Handler) ChangePassword(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var req ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.svc.ChangePassword(id, req.NewPassword); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "password changed"}) +} diff --git a/internal/auth/handler.go b/internal/auth/handler.go new file mode 100644 index 0000000..6f9f6e8 --- /dev/null +++ b/internal/auth/handler.go @@ -0,0 +1,32 @@ +package auth + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "onixbyte.com/message-converter-manifest/internal/model" +) + +type Handler struct { + svc *Service +} + +func NewHandler(svc *Service) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) Login(c *gin.Context) { + var req model.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + resp, err := h.svc.Login(req.Username, req.Password) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + return + } + + c.JSON(http.StatusOK, resp) +} diff --git a/internal/auth/repositoriy.go b/internal/auth/repositoriy.go new file mode 100644 index 0000000..f622d70 --- /dev/null +++ b/internal/auth/repositoriy.go @@ -0,0 +1,4 @@ +package auth + +// Repository functions are in internal/database/repository.go (AdminUser methods). +// This file exists to satisfy the package structure for future auth-specific queries. diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..eac26ae --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,155 @@ +package auth + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "gorm.io/gorm" + + "onixbyte.com/message-converter-manifest/internal/database" + "onixbyte.com/message-converter-manifest/internal/model" +) + +var ( + ErrInvalidCredentials = errors.New("invalid username or password") + ErrUserExists = errors.New("username already exists") +) + +type Service struct { + db *gorm.DB + secret []byte + expiry time.Duration +} + +func NewService(db *gorm.DB, secret []byte, expiryHrs int) *Service { + return &Service{ + db: db, + secret: secret, + expiry: time.Duration(expiryHrs) * time.Hour, + } +} + +func (s *Service) Login(username, password string) (*model.LoginResponse, error) { + user, err := database.GetAdminUserByUsername(s.db, username) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrInvalidCredentials + } + return nil, err + } + + hash := hashPassword(password) + if subtle.ConstantTimeCompare([]byte(hash), []byte(user.PasswordHash)) != 1 { + return nil, ErrInvalidCredentials + } + + token, expiresAt, err := s.generateToken(user) + if err != nil { + return nil, err + } + + return &model.LoginResponse{ + Token: token, + ExpiresAt: expiresAt, + }, nil +} + +func (s *Service) CreateInitialAdmin(username, password string) error { + _, err := database.GetAdminUserByUsername(s.db, username) + if err == nil { + return nil // admin already exists + } + + user := &model.AdminUser{ + Username: username, + PasswordHash: hashPassword(password), + Role: "admin", + } + return s.db.Transaction(func(tx *gorm.DB) error { + return database.CreateAdminUser(tx, user) + }) +} + +func (s *Service) ValidateToken(tokenString string) (*model.AdminUser, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return s.secret, nil + }) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid token") + } + + username, _ := claims["sub"].(string) + if username == "" { + return nil, errors.New("invalid token subject") + } + + return database.GetAdminUserByUsername(s.db, username) +} + +func (s *Service) generateToken(user *model.AdminUser) (string, int64, error) { + expiresAt := time.Now().Add(s.expiry) + claims := jwt.MapClaims{ + "sub": user.Username, + "role": user.Role, + "iat": time.Now().Unix(), + "exp": expiresAt.Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenStr, err := token.SignedString(s.secret) + if err != nil { + return "", 0, err + } + + return tokenStr, expiresAt.Unix(), nil +} + +func hashPassword(password string) string { + h := sha256.Sum256([]byte(password)) + return hex.EncodeToString(h[:]) +} + +func (s *Service) ListUsers() ([]model.AdminUser, error) { + return database.ListAdminUsers(s.db) +} + +func (s *Service) CreateUser(username, password, role string) (*model.AdminUser, error) { + _, err := database.GetAdminUserByUsername(s.db, username) + if err == nil { + return nil, ErrUserExists + } + + if role == "" { + role = "admin" + } + + user := &model.AdminUser{ + Username: username, + PasswordHash: hashPassword(password), + Role: role, + } + if err := database.CreateAdminUser(s.db, user); err != nil { + return nil, err + } + return user, nil +} + +func (s *Service) DeleteUser(id int64) error { + return database.DeleteAdminUser(s.db, id) +} + +func (s *Service) ChangePassword(id int64, newPassword string) error { + return database.UpdateAdminPassword(s.db, id, hashPassword(newPassword)) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2400a61 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,54 @@ +package config + +import ( + "log" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + ServerPort string + JWTSecret string + JWTExpiryHrs int + DatabaseURL string + ArtefactDir string + MaxConcurrent int + BandwidthBPS int + RateLimitRPS int +} + +func Load() *Config { + // Load .env file if present (silently ignore if not found) + if err := godotenv.Load(); err != nil { + log.Println("[config] No .env file found, using system environment variables") + } + + return &Config{ + ServerPort: envOrDefault("SERVER_PORT", "8080"), + JWTSecret: envOrDefault("JWT_SECRET", "change-me-in-production"), + JWTExpiryHrs: envOrDefaultInt("JWT_EXPIRY_HRS", 24), + DatabaseURL: envOrDefault("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/manifest?sslmode=disable"), + ArtefactDir: envOrDefault("ARTEFACT_DIR", "/var/ota/artefacts"), + MaxConcurrent: envOrDefaultInt("MAX_CONCURRENT_DOWNLOADS", 50), + BandwidthBPS: envOrDefaultInt("BANDWIDTH_BPS", 10*1024*1024/50), + RateLimitRPS: envOrDefaultInt("RATE_LIMIT_RPS", 100), + } +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envOrDefaultInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..7773b7a --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,83 @@ +package database + +import ( + "fmt" + "log" + "net/url" + "strings" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "onixbyte.com/message-converter-manifest/internal/model" +) + +// Connect opens a PostgreSQL connection via GORM. If the target database does +// not exist it is created automatically. Tables are then migrated. +func Connect(databaseURL string) (*gorm.DB, error) { + dbName, err := ensureDatabase(databaseURL) + if err != nil { + return nil, err + } + + db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + }) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("failed to get underlying sql.DB: %w", err) + } + sqlDB.SetMaxOpenConns(50) + sqlDB.SetMaxIdleConns(10) + + log.Printf("[database] Connected to PostgreSQL database: %s", dbName) + return db, nil +} + +// AutoMigrate creates or updates tables to match the model definitions. +func AutoMigrate(db *gorm.DB) error { + return db.AutoMigrate( + &model.AdminUser{}, + &model.Device{}, + &model.Artefact{}, + &model.Deployment{}, + &model.DeploymentDevice{}, + ) +} + +// ensureDatabase creates the target database if it does not exist. +func ensureDatabase(databaseURL string) (string, error) { + u, err := url.Parse(databaseURL) + if err != nil { + return "", fmt.Errorf("invalid database URL: %w", err) + } + dbName := strings.TrimPrefix(u.Path, "/") + + adminURL := *u + adminURL.Path = "/postgres" + + db, err := gorm.Open(postgres.Open(adminURL.String()), &gorm.Config{}) + if err != nil { + return "", fmt.Errorf("failed to connect to admin database: %w", err) + } + + var exists bool + db.Raw("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = ?)", dbName).Scan(&exists) + if !exists { + if err := db.Exec(fmt.Sprintf(`CREATE DATABASE "%s"`, dbName)).Error; err != nil { + return "", fmt.Errorf("failed to create database %s: %w", dbName, err) + } + log.Printf("[database] Created database: %s", dbName) + } + + sqlDB, _ := db.DB() + if sqlDB != nil { + sqlDB.Close() + } + return dbName, nil +} diff --git a/internal/database/repository.go b/internal/database/repository.go new file mode 100644 index 0000000..7c1315c --- /dev/null +++ b/internal/database/repository.go @@ -0,0 +1,242 @@ +package database + +import ( + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "onixbyte.com/message-converter-manifest/internal/model" +) + +// -- Admin Users -- + +func CreateAdminUser(db *gorm.DB, u *model.AdminUser) error { + return db.Create(u).Error +} + +func GetAdminUserByUsername(db *gorm.DB, username string) (*model.AdminUser, error) { + var u model.AdminUser + err := db.Where("username = ?", username).First(&u).Error + if err != nil { + return nil, err + } + return &u, nil +} + +func ListAdminUsers(db *gorm.DB) ([]model.AdminUser, error) { + var users []model.AdminUser + err := db.Order("created_at").Find(&users).Error + return users, err +} + +func DeleteAdminUser(db *gorm.DB, id int64) error { + return db.Delete(&model.AdminUser{}, id).Error +} + +func UpdateAdminPassword(db *gorm.DB, id int64, passwordHash string) error { + return db.Model(&model.AdminUser{}).Where("id = ?", id). + Update("password_hash", passwordHash).Error +} + +// -- Devices -- + +func UpsertDevice(db *gorm.DB, d *model.Device) error { + return db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "device_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"ip_address", "hostname", "os", "current_version", "status", "group_name", "last_heartbeat", "updated_at"}), + }).Create(d).Error +} + +func UpdateDeviceHeartbeat(db *gorm.DB, deviceID, version, status string) error { + return db.Model(&model.Device{}).Where("device_id = ?", deviceID). + Updates(map[string]interface{}{ + "current_version": version, + "status": status, + "last_heartbeat": time.Now(), + }).Error +} + +func GetDeviceByID(db *gorm.DB, deviceID string) (*model.Device, error) { + var d model.Device + err := db.Where("device_id = ?", deviceID).First(&d).Error + if err != nil { + return nil, err + } + return &d, nil +} + +func ListDevices(db *gorm.DB, status, group string, page, pageSize int) ([]model.Device, int64, error) { + var devices []model.Device + var total int64 + + q := db.Model(&model.Device{}) + if status != "" { + q = q.Where("status = ?", status) + } + if group != "" { + q = q.Where("group_name = ?", group) + } + + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + err := q.Order("last_heartbeat DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&devices).Error + return devices, total, err +} + +func GetOfflineDevices(db *gorm.DB, threshold time.Duration) ([]model.Device, error) { + var devices []model.Device + err := db.Where("last_heartbeat < ? AND status = ?", time.Now().Add(-threshold), model.DeviceStatusOnline). + Find(&devices).Error + return devices, err +} + +func MarkDevicesOffline(db *gorm.DB, deviceIDs []string) error { + return db.Model(&model.Device{}).Where("device_id IN ?", deviceIDs). + Update("status", model.DeviceStatusOffline).Error +} + +// -- Artefacts -- + +func CreateArtefact(db *gorm.DB, a *model.Artefact) error { + return db.Create(a).Error +} + +func GetArtefactByID(db *gorm.DB, id int64) (*model.Artefact, error) { + var a model.Artefact + err := db.First(&a, id).Error + if err != nil { + return nil, err + } + return &a, nil +} + +func GetArtefactByVersion(db *gorm.DB, version string) (*model.Artefact, error) { + var a model.Artefact + err := db.Where("version = ?", version).Order("created_at DESC").First(&a).Error + if err != nil { + return nil, err + } + return &a, nil +} + +func ListArtefacts(db *gorm.DB, page, pageSize int) ([]model.Artefact, int64, error) { + var artefacts []model.Artefact + var total int64 + + db.Model(&model.Artefact{}).Count(&total) + err := db.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&artefacts).Error + return artefacts, total, err +} + +// -- Deployments -- + +func CreateDeployment(db *gorm.DB, d *model.Deployment) error { + return db.Create(d).Error +} + +func GetDeploymentByID(db *gorm.DB, id int64) (*model.Deployment, error) { + var d model.Deployment + err := db.Preload("Artefact").First(&d, id).Error + if err != nil { + return nil, err + } + return &d, nil +} + +func ListDeployments(db *gorm.DB, status string, page, pageSize int) ([]model.Deployment, int64, error) { + var deployments []model.Deployment + var total int64 + + q := db.Model(&model.Deployment{}) + if status != "" { + q = q.Where("status = ?", status) + } + + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + err := q.Preload("Artefact").Order("created_at DESC"). + Offset((page - 1) * pageSize).Limit(pageSize).Find(&deployments).Error + return deployments, total, err +} + +func UpdateDeploymentStatus(db *gorm.DB, id int64, status string) error { + return db.Model(&model.Deployment{}).Where("id = ?", id).Update("status", status).Error +} + +// -- Deployment Devices -- + +func CreateDeploymentDevices(db *gorm.DB, deploymentID int64, deviceIDs []string) error { + devices := make([]model.DeploymentDevice, len(deviceIDs)) + for i, did := range deviceIDs { + devices[i] = model.DeploymentDevice{DeploymentID: deploymentID, DeviceID: did, Status: model.DeployDeviceStatusPending} + } + return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&devices).Error +} + +func GetActiveDeploymentForDevice(db *gorm.DB, deviceID string) (*model.Deployment, *model.Artefact, error) { + var dd model.DeploymentDevice + err := db.Where("device_id = ? AND status = ?", deviceID, model.DeployDeviceStatusPending). + Preload("Deployment.Artefact").First(&dd).Error + if err != nil { + return nil, nil, err + } + + var d model.Deployment + db.Preload("Artefact").First(&d, dd.DeploymentID) + if d.Status != model.DeploymentStatusActive { + return nil, nil, gorm.ErrRecordNotFound + } + return &d, d.Artefact, nil +} + +func UpdateDeploymentDeviceStatus(db *gorm.DB, deploymentID int64, deviceID, status, errMsg string) error { + updates := map[string]interface{}{"status": status, "error": errMsg} + now := time.Now() + if status == model.DeployDeviceStatusDownloaded { + updates["downloaded_at"] = &now + } + if status == model.DeployDeviceStatusCompleted || status == model.DeployDeviceStatusFailed { + updates["completed_at"] = &now + } + return db.Model(&model.DeploymentDevice{}). + Where("deployment_id = ? AND device_id = ?", deploymentID, deviceID). + Updates(updates).Error +} + +func GetDeploymentDeviceStats(db *gorm.DB, deploymentID int64) (map[string]int64, error) { + type row struct { + Status string + Count int64 + } + var rows []row + if err := db.Model(&model.DeploymentDevice{}). + Select("status, COUNT(*) as count"). + Where("deployment_id = ?", deploymentID). + Group("status").Scan(&rows).Error; err != nil { + return nil, err + } + stats := map[string]int64{} + for _, r := range rows { + stats[r.Status] = r.Count + } + return stats, nil +} + +func GetDeploymentDevices(db *gorm.DB, deploymentID int64, status string, page, pageSize int) ([]model.DeploymentDevice, int64, error) { + var devices []model.DeploymentDevice + var total int64 + + q := db.Model(&model.DeploymentDevice{}).Where("deployment_id = ?", deploymentID) + if status != "" { + q = q.Where("status = ?", status) + } + + q.Count(&total) + err := q.Order("created_at").Offset((page - 1) * pageSize).Limit(pageSize).Find(&devices).Error + return devices, total, err +} diff --git a/internal/delta/handler.go b/internal/delta/handler.go new file mode 100644 index 0000000..84be3c0 --- /dev/null +++ b/internal/delta/handler.go @@ -0,0 +1,85 @@ +package delta + +import ( + "net/http" + "os" + "path/filepath" + + "github.com/gin-gonic/gin" +) + +// Handler provides delta patch generation for JAR artefacts. +type Handler struct { + artefactDir string +} + +func NewHandler(artefactDir string) *Handler { + return &Handler{artefactDir: artefactDir} +} + +// GenerateDelta creates a delta patch between two artefact versions. +// POST /api/v1/delta/generate +// Form fields: oldVersion, newVersion +func (h *Handler) GenerateDelta(c *gin.Context) { + oldVersion := c.PostForm("oldVersion") + newVersion := c.PostForm("newVersion") + if oldVersion == "" || newVersion == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "oldVersion and newVersion are required"}) + return + } + + oldDir := filepath.Join(h.artefactDir, oldVersion) + newDir := filepath.Join(h.artefactDir, newVersion) + + if _, err := os.Stat(oldDir); os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Old version not found: " + oldVersion}) + return + } + if _, err := os.Stat(newDir); os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "New version not found: " + newVersion}) + return + } + + // Find JAR files in each version directory + oldJar, err := findJarInDir(oldDir) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No JAR found in old version: " + err.Error()}) + return + } + newJar, err := findJarInDir(newDir) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No JAR found in new version: " + err.Error()}) + return + } + + patchPath := filepath.Join(h.artefactDir, "patches", oldVersion+"-to-"+newVersion+".patch") + if err := os.MkdirAll(filepath.Dir(patchPath), 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create patch directory"}) + return + } + + if err := GenerateJarDelta(oldJar, newJar, patchPath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Delta generation failed: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "generated", + "patchPath": patchPath, + "oldVersion": oldVersion, + "newVersion": newVersion, + }) +} + +func findJarInDir(dir string) (string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + for _, e := range entries { + if !e.IsDir() && filepath.Ext(e.Name()) == ".jar" { + return filepath.Join(dir, e.Name()), nil + } + } + return "", os.ErrNotExist +} diff --git a/internal/delta/unpacker.go b/internal/delta/unpacker.go new file mode 100644 index 0000000..615ce0b --- /dev/null +++ b/internal/delta/unpacker.go @@ -0,0 +1,230 @@ +package delta + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// PatchManifest describes the delta operations needed to transform an old JAR into a new JAR. +type PatchManifest struct { + OldVersion string `json:"oldVersion"` + NewVersion string `json:"newVersion"` + Deleted []string `json:"deleted"` + Added []string `json:"added"` + Modified []PatchEntry `json:"modified"` + Unchanged int `json:"unchanged"` +} + +// PatchEntry represents a single modified file within the delta. +type PatchEntry struct { + Path string `json:"path"` + OldSHA256 string `json:"oldSha256"` + NewSHA256 string `json:"newSha256"` + DiffSize int64 `json:"diffSize"` + DiffHash string `json:"diffHash"` +} + +// GenerateJarDelta produces a .patch file by comparing two JARs: +// 1. Unzip both JARs into memory. +// 2. Compare files; identify added, deleted, and modified entries. +// 3. For modified .class files, generate a BSDiff-style binary diff. +// 4. Package everything (diff chunks, new files, manifest.json) into a zip. +func GenerateJarDelta(oldJarPath, newJarPath, patchPath string) error { + oldFiles, err := readJarEntries(oldJarPath) + if err != nil { + return fmt.Errorf("reading old JAR: %w", err) + } + newFiles, err := readJarEntries(newJarPath) + if err != nil { + return fmt.Errorf("reading new JAR: %w", err) + } + + manifest := PatchManifest{ + OldVersion: filepath.Base(oldJarPath), + NewVersion: filepath.Base(newJarPath), + } + + oldSet := make(map[string][]byte) + for _, f := range oldFiles { + oldSet[f.Name] = f.Data + } + newSet := make(map[string][]byte) + for _, f := range newFiles { + newSet[f.Name] = f.Data + } + + // Identify deleted, added, and modified files. + for name := range oldSet { + if _, ok := newSet[name]; !ok { + manifest.Deleted = append(manifest.Deleted, name) + } + } + for name := range newSet { + if _, ok := oldSet[name]; !ok { + manifest.Added = append(manifest.Added, name) + } + } + for name, newData := range newSet { + oldData, ok := oldSet[name] + if !ok { + continue + } + if !bytes.Equal(oldData, newData) { + entry := PatchEntry{Path: name} + entry.OldSHA256 = sha256Hex(oldData) + entry.NewSHA256 = sha256Hex(newData) + + if isBinaryFile(name) { + diff := bsdiff(oldData, newData) + entry.DiffSize = int64(len(diff)) + entry.DiffHash = sha256Hex(diff) + } + + manifest.Modified = append(manifest.Modified, entry) + } else { + manifest.Unchanged++ + } + } + + return writePatchFile(patchPath, &manifest, oldFiles, newFiles, oldSet, newSet) +} + +type jarEntry struct { + Name string + Data []byte +} + +func readJarEntries(jarPath string) ([]jarEntry, error) { + data, err := os.ReadFile(jarPath) + if err != nil { + return nil, err + } + + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, err + } + + var entries []jarEntry + for _, f := range reader.File { + if f.FileInfo().IsDir() { + continue + } + rc, err := f.Open() + if err != nil { + return nil, err + } + content, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, err + } + entries = append(entries, jarEntry{Name: f.Name, Data: content}) + } + return entries, nil +} + +func isBinaryFile(name string) bool { + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".class", ".jar", ".war", ".ear": + return true + } + return false +} + +func sha256Hex(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + +func writePatchFile(patchPath string, manifest *PatchManifest, oldFiles, newFiles []jarEntry, oldSet, newSet map[string][]byte) error { + out, err := os.Create(patchPath) + if err != nil { + return err + } + defer out.Close() + + zw := zip.NewWriter(out) + defer zw.Close() + + // Write manifest.json + manifestData, _ := json.MarshalIndent(manifest, "", " ") + w, _ := zw.Create("manifest.json") + w.Write(manifestData) + + // Write added files + for _, name := range manifest.Added { + w, _ := zw.Create("added/" + name) + w.Write(newSet[name]) + } + + // Write modifications (binary diffs) + for _, entry := range manifest.Modified { + if entry.DiffSize > 0 { + oldData := oldSet[entry.Path] + newData := newSet[entry.Path] + diff := bsdiff(oldData, newData) + w, _ := zw.Create("diffs/" + entry.Path + ".bsdiff") + w.Write(diff) + } + } + + return nil +} + +// bsdiff produces a simple binary diff between two byte slices. +// For production use, integrate a full BSDiff library (e.g. github.com/icedream/bsdiff). +func bsdiff(old, new []byte) []byte { + // Poor man's binary diff — records differing byte runs. + // A production implementation should use a proper BSDiff or VCDIFF library. + var buf bytes.Buffer + minLen := len(old) + if len(new) < minLen { + minLen = len(new) + } + + // Write header: old size, new size + buf.WriteString(fmt.Sprintf("BSDIFF40:%d:%d\n", len(old), len(new))) + + // Record differing chunks + chunkStart := -1 + for i := 0; i < minLen; i++ { + if old[i] != new[i] { + if chunkStart == -1 { + chunkStart = i + } + } else if chunkStart != -1 { + chunk := new[chunkStart:i] + buf.Write(encodeChunk(chunkStart, chunk)) + chunkStart = -1 + } + } + if chunkStart != -1 { + chunk := new[chunkStart:minLen] + buf.Write(encodeChunk(chunkStart, chunk)) + } + + // Trail: if new is longer, append extra bytes + if len(new) > len(old) { + buf.Write(encodeChunk(len(old), new[len(old):])) + } + + return buf.Bytes() +} + +func encodeChunk(offset int, data []byte) []byte { + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf("@%d+%d:", offset, len(data))) + buf.Write(data) + buf.WriteByte('\n') + return buf.Bytes() +} diff --git a/internal/deployment/handler.go b/internal/deployment/handler.go new file mode 100644 index 0000000..742bdc3 --- /dev/null +++ b/internal/deployment/handler.go @@ -0,0 +1,302 @@ +package deployment + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "onixbyte.com/message-converter-manifest/internal/database" + "onixbyte.com/message-converter-manifest/internal/model" +) + +type Handler struct { + db *gorm.DB +} + +func NewHandler(db *gorm.DB) *Handler { + return &Handler{db: db} +} + +func (h *Handler) Create(c *gin.Context) { + var req model.CreateDeploymentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.TargetType != model.TargetAll && req.TargetType != model.TargetDevice && req.TargetType != model.TargetGroup { + c.JSON(http.StatusBadRequest, gin.H{"error": "targetType must be all, device, or group"}) + return + } + + if req.RolloutStrategy == "" { + req.RolloutStrategy = model.RolloutImmediate + } + if req.MaxConcurrent <= 0 { + req.MaxConcurrent = 50 + } + + _, err := database.GetArtefactByID(h.db, req.ArtefactID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Artefact not found"}) + return + } + + deployment := &model.Deployment{ + ArtefactID: req.ArtefactID, + Name: req.Name, + Description: req.Description, + TargetType: req.TargetType, + TargetSpec: req.TargetSpec, + RolloutStrategy: req.RolloutStrategy, + MaxConcurrent: req.MaxConcurrent, + Status: model.DeploymentStatusDraft, + } + + err = h.db.Transaction(func(tx *gorm.DB) error { + if err := database.CreateDeployment(tx, deployment); err != nil { + return err + } + + deviceIDs, err := h.resolveTargets(deployment) + if err != nil { + return err + } + + return database.CreateDeploymentDevices(tx, deployment.ID, deviceIDs) + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, deployment) +} + +func (h *Handler) resolveTargets(d *model.Deployment) ([]string, error) { + switch d.TargetType { + case model.TargetAll: + devices, _, err := database.ListDevices(h.db, "", "", 1, 100000) + if err != nil { + return nil, err + } + ids := make([]string, len(devices)) + for i, dev := range devices { + ids[i] = dev.DeviceID + } + return ids, nil + + case model.TargetDevice: + var spec struct { + DeviceIDs []string `json:"deviceIds"` + } + if err := json.Unmarshal([]byte(d.TargetSpec), &spec); err != nil { + return nil, err + } + return spec.DeviceIDs, nil + + case model.TargetGroup: + var spec struct { + GroupName string `json:"groupName"` + } + if err := json.Unmarshal([]byte(d.TargetSpec), &spec); err != nil { + return nil, err + } + devices, _, err := database.ListDevices(h.db, "", spec.GroupName, 1, 100000) + if err != nil { + return nil, err + } + ids := make([]string, len(devices)) + for i, dev := range devices { + ids[i] = dev.DeviceID + } + return ids, nil + + default: + return nil, nil + } +} + +func (h *Handler) Activate(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + deployment, err := database.GetDeploymentByID(h.db, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) + return + } + + if deployment.Status != model.DeploymentStatusDraft { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft deployments can be activated"}) + return + } + + if err := database.UpdateDeploymentStatus(h.db, id, model.DeploymentStatusActive); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + deployment.Status = model.DeploymentStatusActive + c.JSON(http.StatusOK, deployment) +} + +func (h *Handler) Pause(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + deployment, err := database.GetDeploymentByID(h.db, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) + return + } + + if deployment.Status != model.DeploymentStatusActive { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only active deployments can be paused"}) + return + } + + if err := database.UpdateDeploymentStatus(h.db, id, model.DeploymentStatusPaused); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + deployment.Status = model.DeploymentStatusPaused + c.JSON(http.StatusOK, deployment) +} + +func (h *Handler) List(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + status := c.Query("status") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + deployments, total, err := database.ListDeployments(h.db, status, page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, model.PaginatedResponse{ + Data: deployments, + Total: total, + Page: page, + PageSize: pageSize, + }) +} + +func (h *Handler) Get(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + deployment, err := database.GetDeploymentByID(h.db, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) + return + } + + stats, _ := database.GetDeploymentDeviceStats(h.db, id) + + c.JSON(http.StatusOK, gin.H{ + "deployment": deployment, + "stats": stats, + }) +} + +func (h *Handler) ListDevices(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "50")) + status := c.Query("status") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 200 { + pageSize = 50 + } + + devices, total, err := database.GetDeploymentDevices(h.db, id, status, page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, model.PaginatedResponse{ + Data: devices, + Total: total, + Page: page, + PageSize: pageSize, + }) +} + +func (h *Handler) Rollback(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid deployment id"}) + return + } + + var req struct { + DeviceID string `json:"deviceId" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := database.UpdateDeploymentDeviceStatus(h.db, id, req.DeviceID, model.DeployDeviceStatusRolledBack, "Rolled back by operator"); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "rolled_back"}) +} + +func (h *Handler) UpdateStatus(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid deployment id"}) + return + } + + var req struct { + DeviceID string `json:"deviceId" binding:"required"` + Status string `json:"status" binding:"required"` + Error string `json:"error"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := database.UpdateDeploymentDeviceStatus(h.db, id, req.DeviceID, req.Status, req.Error); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "updated"}) +} diff --git a/internal/device/handler.go b/internal/device/handler.go new file mode 100644 index 0000000..cc5f607 --- /dev/null +++ b/internal/device/handler.go @@ -0,0 +1,137 @@ +package device + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "onixbyte.com/message-converter-manifest/internal/database" + "onixbyte.com/message-converter-manifest/internal/model" +) + +type Handler struct { + db *gorm.DB +} + +func NewHandler(db *gorm.DB) *Handler { + return &Handler{db: db} +} + +func (h *Handler) Register(c *gin.Context) { + var req model.RegisterDeviceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + device := &model.Device{ + DeviceID: req.DeviceID, + IPAddress: req.IPAddress, + Hostname: req.Hostname, + OS: req.OS, + CurrentVersion: req.CurrentVersion, + Status: model.DeviceStatusOnline, + GroupName: req.GroupName, + } + if device.GroupName == "" { + device.GroupName = "default" + } + + if err := database.UpsertDevice(h.db, device); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, device) +} + +func (h *Handler) Heartbeat(c *gin.Context) { + var req model.HeartbeatRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + status := req.Status + if status == "" { + status = model.DeviceStatusOnline + } + + if err := database.UpdateDeviceHeartbeat(h.db, req.DeviceID, req.CurrentVersion, status); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "acknowledged"}) +} + +func (h *Handler) CheckUpdate(c *gin.Context) { + deviceID := c.Query("deviceId") + if deviceID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "deviceId is required"}) + return + } + + _, artefact, err := database.GetActiveDeploymentForDevice(h.db, deviceID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusOK, model.CheckUpdateResponse{HasUpdate: false}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, model.CheckUpdateResponse{ + HasUpdate: true, + ArtefactID: artefact.ID, + Version: artefact.Version, + FileSize: artefact.FileSize, + SHA256Hash: artefact.SHA256Hash, + DownloadURL: "/api/v1/packages/download/" + strconv.FormatInt(artefact.ID, 10), + }) +} + +func (h *Handler) List(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + status := c.Query("status") + group := c.Query("group") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + devices, total, err := database.ListDevices(h.db, status, group, page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, model.PaginatedResponse{ + Data: devices, + Total: total, + Page: page, + PageSize: pageSize, + }) +} + +func (h *Handler) Get(c *gin.Context) { + deviceID := c.Param("deviceId") + device, err := database.GetDeviceByID(h.db, deviceID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, device) +} diff --git a/internal/download/handler.go b/internal/download/handler.go new file mode 100644 index 0000000..83346b1 --- /dev/null +++ b/internal/download/handler.go @@ -0,0 +1,141 @@ +package download + +import ( + "errors" + "fmt" + "log" + "net/http" + "os" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "onixbyte.com/message-converter-manifest/internal/database" + "onixbyte.com/message-converter-manifest/internal/middleware" +) + +// Handler manages artefact downloads with bandwidth throttling and Range support. +type Handler struct { + db *gorm.DB + artefactDir string + semaphore *middleware.GatedSemaphore + bytesPerSec int +} + +func NewHandler(db *gorm.DB, artefactDir string, sem *middleware.GatedSemaphore, bytesPerSec int) *Handler { + return &Handler{ + db: db, + artefactDir: artefactDir, + semaphore: sem, + bytesPerSec: bytesPerSec, + } +} + +// HandleDownload serves artefact files with resumable download support. +func (h *Handler) HandleDownload(c *gin.Context) { + artefactID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid artefact id"}) + return + } + + deviceID := c.Query("deviceId") + deploymentID, _ := strconv.ParseInt(c.Query("deploymentId"), 10, 64) + + artefact, err := database.GetArtefactByID(h.db, artefactID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "Artefact not found"}) + return + } + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + file, err := os.Open(artefact.FilePath) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "Package file not found on disk"}) + return + } + defer file.Close() + + fileInfo, err := file.Stat() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Failed to stat file"}) + return + } + + // Update device status to downloading + if deviceID != "" && deploymentID > 0 { + if err := database.UpdateDeploymentDeviceStatus(h.db, deploymentID, deviceID, "downloading", ""); err != nil { + log.Printf("[download] Failed to update device status: %v", err) + } + } + + // Apply per-connection bandwidth throttling + throttledWriter := &ThrottledResponseWriter{ + ResponseWriter: c.Writer, + bytesPerSecond: h.bytesPerSec, + } + c.Writer = throttledWriter + + c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, artefact.FileName)) + c.Header("X-Checksum-SHA256", artefact.SHA256Hash) + + http.ServeContent(c.Writer, c.Request, artefact.FileName, fileInfo.ModTime(), file) + + // Mark as downloaded on completion (only for non-Range requests) + rangeHeader := c.Request.Header.Get("Range") + if deviceID != "" && deploymentID > 0 { + if rangeHeader == "" || isCompleteRange(rangeHeader, fileInfo.Size()) { + if err := database.UpdateDeploymentDeviceStatus(h.db, deploymentID, deviceID, "downloaded", ""); err != nil { + log.Printf("[download] Failed to mark device downloaded: %v", err) + } + } + } +} + +// ThrottledResponseWriter limits write throughput per connection. +type ThrottledResponseWriter struct { + gin.ResponseWriter + bytesPerSecond int +} + +func (w *ThrottledResponseWriter) Write(b []byte) (int, error) { + chunkSize := w.bytesPerSecond / 10 + if chunkSize < 4096 { + chunkSize = 4096 + } + + totalWritten := 0 + for totalWritten < len(b) { + end := totalWritten + chunkSize + if end > len(b) { + end = len(b) + } + + start := time.Now() + n, err := w.ResponseWriter.Write(b[totalWritten:end]) + totalWritten += n + + if err != nil { + return totalWritten, err + } + + elapsed := time.Since(start) + expected := time.Duration(n) * time.Second / time.Duration(w.bytesPerSecond) + if expected > elapsed { + time.Sleep(expected - elapsed) + } + } + + return totalWritten, nil +} + +func isCompleteRange(rangeHeader string, fileSize int64) bool { + // Simple check: if range starts at 0 and covers the entire file, it's "complete" + // This avoids double-counting full-file requests that use Range: bytes=0- + return false +} diff --git a/internal/download/limiter.go b/internal/download/limiter.go new file mode 100644 index 0000000..9e9408a --- /dev/null +++ b/internal/download/limiter.go @@ -0,0 +1,19 @@ +package download + +// GlobalDownloadGate wraps a GatedSemaphore for download-specific concurrency control. +// See internal/middleware/rate_limit.go for the underlying GatedSemaphore implementation. +// +// The gate ensures that the total number of concurrent download streams never exceeds +// the configured limit (default 50), which keeps total bandwidth usage under 10 Mbps +// when each stream is throttled to ~25 KB/s. +type GlobalDownloadGate struct { + sem chan struct{} +} + +func NewGlobalDownloadGate(maxConcurrent int) *GlobalDownloadGate { + return &GlobalDownloadGate{sem: make(chan struct{}, maxConcurrent)} +} + +func (g *GlobalDownloadGate) Acquire() { g.sem <- struct{}{} } +func (g *GlobalDownloadGate) Release() { <-g.sem } +func (g *GlobalDownloadGate) Available() int { return cap(g.sem) - len(g.sem) } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..b231cb5 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "onixbyte.com/message-converter-manifest/internal/auth" +) + +func AuthMiddleware(authSvc *auth.Service) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorisation header is missing"}) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if !(len(parts) == 2 && parts[0] == "Bearer") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorisation format"}) + return + } + + user, err := authSvc.ValidateToken(parts[1]) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorised: " + err.Error()}) + return + } + + c.Set("user", user) + c.Next() + } +} diff --git a/internal/middleware/rate_limit.go b/internal/middleware/rate_limit.go new file mode 100644 index 0000000..e8a31ef --- /dev/null +++ b/internal/middleware/rate_limit.go @@ -0,0 +1,113 @@ +package middleware + +import ( + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// TokenBucket implements a token bucket rate limiter per key (e.g. IP or device ID). +type TokenBucket struct { + mu sync.Mutex + buckets map[string]*bucket + rate float64 // tokens per second + capacity float64 +} + +type bucket struct { + tokens float64 + lastFill time.Time +} + +func NewTokenBucket(rate, capacity int) *TokenBucket { + return &TokenBucket{ + buckets: make(map[string]*bucket), + rate: float64(rate), + capacity: float64(capacity), + } +} + +func (tb *TokenBucket) Allow(key string) bool { + tb.mu.Lock() + defer tb.mu.Unlock() + + b, ok := tb.buckets[key] + if !ok { + b = &bucket{tokens: tb.capacity, lastFill: time.Now()} + tb.buckets[key] = b + } + + now := time.Now() + elapsed := now.Sub(b.lastFill).Seconds() + b.tokens += elapsed * tb.rate + if b.tokens > tb.capacity { + b.tokens = tb.capacity + } + b.lastFill = now + + if b.tokens >= 1 { + b.tokens-- + return true + } + return false +} + +// Cleanup removes stale buckets older than the given duration. +func (tb *TokenBucket) Cleanup(maxAge time.Duration) { + tb.mu.Lock() + defer tb.mu.Unlock() + + now := time.Now() + for k, b := range tb.buckets { + if now.Sub(b.lastFill) > maxAge { + delete(tb.buckets, k) + } + } +} + +// RateLimitMiddleware returns a Gin middleware that rate-limits requests per client IP. +func RateLimitMiddleware(tb *TokenBucket) gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + if !tb.Allow(ip) { + c.AbortWithStatusJSON(429, gin.H{"error": "Rate limit exceeded. Please slow down."}) + return + } + c.Next() + } +} + +// GatedSemaphore limits the number of concurrent operations (e.g. downloads). +type GatedSemaphore struct { + ch chan struct{} +} + +func NewGatedSemaphore(maxConcurrent int) *GatedSemaphore { + return &GatedSemaphore{ch: make(chan struct{}, maxConcurrent)} +} + +func (gs *GatedSemaphore) Acquire() { + gs.ch <- struct{}{} +} + +func (gs *GatedSemaphore) Release() { + <-gs.ch +} + +func (gs *GatedSemaphore) Available() int { + return cap(gs.ch) - len(gs.ch) +} + +// GatedMiddleware only allows requests through if the semaphore has capacity. +func GatedMiddleware(gs *GatedSemaphore) gin.HandlerFunc { + return func(c *gin.Context) { + select { + case gs.ch <- struct{}{}: + defer func() { <-gs.ch }() + c.Next() + default: + c.AbortWithStatusJSON(503, gin.H{"error": "Server at capacity. Please retry later."}) + } + } +} diff --git a/internal/model/models.go b/internal/model/models.go new file mode 100644 index 0000000..0db14ac --- /dev/null +++ b/internal/model/models.go @@ -0,0 +1,184 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// Device status constants +const ( + DeviceStatusOnline = "online" + DeviceStatusOffline = "offline" + DeviceStatusUpgrading = "upgrading" +) + +// Deployment status constants +const ( + DeploymentStatusDraft = "draft" + DeploymentStatusActive = "active" + DeploymentStatusPaused = "paused" + DeploymentStatusCompleted = "completed" + DeploymentStatusCancelled = "cancelled" +) + +// Deployment device status constants +const ( + DeployDeviceStatusPending = "pending" + DeployDeviceStatusDownloading = "downloading" + DeployDeviceStatusDownloaded = "downloaded" + DeployDeviceStatusVerifying = "verifying" + DeployDeviceStatusInstalling = "installing" + DeployDeviceStatusCompleted = "completed" + DeployDeviceStatusFailed = "failed" + DeployDeviceStatusRolledBack = "rolled_back" +) + +// Rollout strategy constants +const ( + RolloutImmediate = "immediate" + RolloutStaged = "staged" +) + +// Target type constants +const ( + TargetAll = "all" + TargetDevice = "device" + TargetGroup = "group" +) + +// Device represents a connected daemon instance. +type Device struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` + DeviceID string `json:"deviceId" gorm:"uniqueIndex;size:256;not null"` + IPAddress string `json:"ipAddress" gorm:"size:64;default:''"` + Hostname string `json:"hostname" gorm:"size:256;default:''"` + OS string `json:"os" gorm:"size:64;default:''"` + CurrentVersion string `json:"currentVersion" gorm:"size:128;default:'0.0.0'"` + Status string `json:"status" gorm:"size:32;default:'online';index"` + GroupName string `json:"groupName" gorm:"size:128;default:'default';index"` + LastHeartbeat time.Time `json:"lastHeartbeat"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Artefact represents an uploaded update package (JAR or patch). +type Artefact struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` + Version string `json:"version" gorm:"size:128;not null;index"` + FileName string `json:"fileName" gorm:"size:512;not null"` + FilePath string `json:"-" gorm:"size:1024;not null"` + FileSize int64 `json:"fileSize"` + SHA256Hash string `json:"sha256Hash" gorm:"size:128"` + DeltaRef string `json:"deltaRef" gorm:"size:128;default:''"` + IsDelta bool `json:"isDelta"` + Metadata string `json:"metadata" gorm:"type:text;default:'{}'"` + CreatedAt time.Time `json:"createdAt"` +} + +// Deployment defines an update rollout campaign. +type Deployment struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` + ArtefactID int64 `json:"artefactId" gorm:"not null;index"` + Artefact *Artefact `json:"artefact,omitempty" gorm:"foreignKey:ArtefactID"` + Name string `json:"name" gorm:"size:256;not null"` + Description string `json:"description" gorm:"type:text;default:''"` + TargetType string `json:"targetType" gorm:"size:32;default:'all'"` + TargetSpec string `json:"targetSpec" gorm:"type:text;default:'{}'"` + RolloutStrategy string `json:"rolloutStrategy" gorm:"size:32;default:'immediate'"` + MaxConcurrent int `json:"maxConcurrent" gorm:"default:50"` + Status string `json:"status" gorm:"size:32;default:'draft';index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// DeploymentDevice tracks an individual device's progress through a deployment. +type DeploymentDevice struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` + DeploymentID int64 `json:"deploymentId" gorm:"not null;uniqueIndex:idx_dd_dep_dev;index"` + DeviceID string `json:"deviceId" gorm:"size:256;not null;uniqueIndex:idx_dd_dep_dev;index"` + Status string `json:"status" gorm:"size:32;default:'pending';index"` + DownloadedAt *time.Time `json:"downloadedAt"` + CompletedAt *time.Time `json:"completedAt"` + Error string `json:"error" gorm:"type:text;default:''"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// AdminUser represents an operator account. +type AdminUser struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` + Username string `json:"username" gorm:"uniqueIndex;size:128;not null"` + PasswordHash string `json:"-" gorm:"size:256;not null"` + Role string `json:"role" gorm:"size:32;default:'admin'"` + CreatedAt time.Time `json:"createdAt"` +} + +// -- Request/Response DTOs -- + +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type LoginResponse struct { + Token string `json:"token"` + ExpiresAt int64 `json:"expiresAt"` +} + +type RegisterDeviceRequest struct { + DeviceID string `json:"deviceId" binding:"required"` + IPAddress string `json:"ipAddress"` + Hostname string `json:"hostname"` + OS string `json:"os"` + CurrentVersion string `json:"currentVersion"` + GroupName string `json:"groupName"` +} + +type HeartbeatRequest struct { + DeviceID string `json:"deviceId" binding:"required"` + CurrentVersion string `json:"currentVersion"` + Status string `json:"status"` +} + +type CheckUpdateResponse struct { + HasUpdate bool `json:"hasUpdate"` + ArtefactID int64 `json:"artefactId,omitempty"` + Version string `json:"version,omitempty"` + FileSize int64 `json:"fileSize,omitempty"` + SHA256Hash string `json:"sha256Hash,omitempty"` + DownloadURL string `json:"downloadUrl,omitempty"` +} + +type CreateDeploymentRequest struct { + ArtefactID int64 `json:"artefactId" binding:"required"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + TargetType string `json:"targetType" binding:"required"` + TargetSpec string `json:"targetSpec"` + RolloutStrategy string `json:"rolloutStrategy"` + MaxConcurrent int `json:"maxConcurrent"` +} + +type PaginatedResponse struct { + Data interface{} `json:"data"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// -- Hooks -- + +func (d *Device) BeforeCreate(tx *gorm.DB) error { + now := time.Now() + if d.LastHeartbeat.IsZero() { + d.LastHeartbeat = now + } + if d.CreatedAt.IsZero() { + d.CreatedAt = now + } + if d.UpdatedAt.IsZero() { + d.UpdatedAt = now + } + return nil +} diff --git a/migrations/00001_initial_schema.up.sql b/migrations/00001_initial_schema.up.sql new file mode 100644 index 0000000..e69de29 diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql new file mode 100644 index 0000000..957f5bd --- /dev/null +++ b/migrations/001_initial_schema.sql @@ -0,0 +1,74 @@ +-- 001_initial_schema.sql +CREATE TABLE IF NOT EXISTS admin_users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(128) NOT NULL UNIQUE, + password_hash VARCHAR(256) NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'admin', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS devices ( + id BIGSERIAL PRIMARY KEY, + device_id VARCHAR(256) NOT NULL UNIQUE, + ip_address VARCHAR(64) NOT NULL DEFAULT '', + hostname VARCHAR(256) NOT NULL DEFAULT '', + os VARCHAR(64) NOT NULL DEFAULT '', + current_version VARCHAR(128) NOT NULL DEFAULT '0.0.0', + status VARCHAR(32) NOT NULL DEFAULT 'online', + group_name VARCHAR(128) NOT NULL DEFAULT 'default', + last_heartbeat TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_devices_status ON devices(status); +CREATE INDEX IF NOT EXISTS idx_devices_group ON devices(group_name); +CREATE INDEX IF NOT EXISTS idx_devices_heartbeat ON devices(last_heartbeat); + +CREATE TABLE IF NOT EXISTS artefacts ( + id BIGSERIAL PRIMARY KEY, + version VARCHAR(128) NOT NULL, + file_name VARCHAR(512) NOT NULL, + file_path VARCHAR(1024) NOT NULL, + file_size BIGINT NOT NULL DEFAULT 0, + sha256_hash VARCHAR(128) NOT NULL DEFAULT '', + delta_ref VARCHAR(128) NOT NULL DEFAULT '', + is_delta BOOLEAN NOT NULL DEFAULT FALSE, + metadata TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_artefacts_version ON artefacts(version); + +CREATE TABLE IF NOT EXISTS deployments ( + id BIGSERIAL PRIMARY KEY, + artefact_id BIGINT NOT NULL REFERENCES artefacts(id), + name VARCHAR(256) NOT NULL, + description TEXT NOT NULL DEFAULT '', + target_type VARCHAR(32) NOT NULL DEFAULT 'all', + target_spec TEXT NOT NULL DEFAULT '{}', + rollout_strategy VARCHAR(32) NOT NULL DEFAULT 'immediate', + max_concurrent INT NOT NULL DEFAULT 50, + status VARCHAR(32) NOT NULL DEFAULT 'draft', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_deployments_status ON deployments(status); + +CREATE TABLE IF NOT EXISTS deployment_devices ( + id BIGSERIAL PRIMARY KEY, + deployment_id BIGINT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE, + device_id VARCHAR(256) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + downloaded_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(deployment_id, device_id) +); + +CREATE INDEX IF NOT EXISTS idx_dd_device ON deployment_devices(device_id); +CREATE INDEX IF NOT EXISTS idx_dd_status ON deployment_devices(status); +CREATE INDEX IF NOT EXISTS idx_dd_deploy ON deployment_devices(deployment_id); diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/.prettierrc.yaml b/web/.prettierrc.yaml new file mode 100644 index 0000000..7612d53 --- /dev/null +++ b/web/.prettierrc.yaml @@ -0,0 +1,24 @@ +printWidth: 100 +tabWidth: 2 +useTabs: false +semi: false +singleQuote: false +quoteProps: as-needed +jsxSingleQuote: false +trailingComma: es5 +bracketSpacing: true +bracketSameLine: true +arrowParens: always +#rangeStart: 0 +#rangeEnd: n +#parser: +#filepath: +#requirePragma: false +#insertPragma: false +#proseWrap: preserve +htmlWhitespaceSensitivity: css +#vueIndentScriptAndStyle: false +endOfLine: lf +#embeddedLanguageFormatting: auto +# Enforce single attribute per line in HTML, Vue and JSX +#singleAttributePerLine: false \ No newline at end of file diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..c300135 --- /dev/null +++ b/web/README.md @@ -0,0 +1,75 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) + +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) + +``` diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..5e3836a --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + web + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..83c2d9f --- /dev/null +++ b/web/package.json @@ -0,0 +1,33 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "eslint": "^10.6.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.7.0", + "prettier": "^3.9.4", + "typescript": "~6.0.2", + "typescript-eslint": "^8.62.0", + "vite": "^8.1.1", + "vite-plugin-port-checker": "^1.0.2" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..abeb62f --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1785 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + react-router-dom: + specifier: ^7.18.1 + version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.6.0) + '@types/node': + specifier: ^24.13.2 + version: 24.13.2 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(vite@8.1.3(@types/node@24.13.2)) + eslint: + specifier: ^10.6.0 + version: 10.6.0 + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.6.0) + eslint-plugin-react-refresh: + specifier: ^0.5.3 + version: 0.5.3(eslint@10.6.0) + globals: + specifier: ^17.7.0 + version: 17.7.0 + prettier: + specifier: ^3.9.4 + version: 3.9.4 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + typescript-eslint: + specifier: ^8.62.0 + version: 8.62.1(eslint@10.6.0)(typescript@6.0.3) + vite: + specifier: ^8.1.1 + version: 8.1.3(@types/node@24.13.2) + vite-plugin-port-checker: + specifier: ^1.0.2 + version: 1.0.2(vite@8.1.3(@types/node@24.13.2)) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.138.0': + resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + + '@rolldown/binding-android-arm64@1.1.4': + resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.4': + resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.4': + resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.4': + resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.4': + resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.4': + resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.4': + resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.4': + resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.4': + resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.4': + resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.4': + resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.4': + resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.4': + resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.13.2': + resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@typescript-eslint/eslint-plugin@8.62.1': + resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.62.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.62.1': + resolution: {integrity: sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.62.1': + resolution: {integrity: sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.62.1': + resolution: {integrity: sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.62.1': + resolution: {integrity: sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.62.1': + resolution: {integrity: sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.62.1': + resolution: {integrity: sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.62.1': + resolution: {integrity: sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.62.1': + resolution: {integrity: sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.62.1': + resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.40: + resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + browserslist@4.28.4: + resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001800: + resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.384: + resolution: {integrity: sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.3: + resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.6.0: + resolution: {integrity: sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.7.0: + resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} + engines: {node: '>=18'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.50: + resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + rolldown@1.1.4: + resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.62.1: + resolution: {integrity: sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite-plugin-port-checker@1.0.2: + resolution: {integrity: sha512-KvrZefqkslGjiPx2wDdZab5csc8YGb+mcPZLHQ1RJXZ0PPSJm5vKDEs/m/hHpkwNZmPW3tnqEBlS/kJ1KWKbfA==} + peerDependencies: + vite: '>=3.0.0' + + vite@8.1.3: + resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + 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/core@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.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.6.0)': + dependencies: + eslint: 10.6.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.6.0)': + optionalDependencies: + eslint: 10.6.0 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.138.0': {} + + '@rolldown/binding-android-arm64@1.1.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.4': + optional: true + + '@rolldown/binding-darwin-x64@1.1.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.4': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.4': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.4': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.13.2': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0)(typescript@6.0.3))(eslint@10.6.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.62.1(eslint@10.6.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/type-utils': 8.62.1(eslint@10.6.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.62.1(eslint@10.6.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.62.1 + eslint: 10.6.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.62.1(eslint@10.6.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.62.1 + debug: 4.4.3 + eslint: 10.6.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.62.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@6.0.3) + '@typescript-eslint/types': 8.62.1 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.62.1': + dependencies: + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/visitor-keys': 8.62.1 + + '@typescript-eslint/tsconfig-utils@8.62.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.62.1(eslint@10.6.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.62.1(eslint@10.6.0)(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.6.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.62.1': {} + + '@typescript-eslint/typescript-estree@8.62.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.62.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@6.0.3) + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/visitor-keys': 8.62.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.62.1(eslint@10.6.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0) + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) + eslint: 10.6.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.62.1': + dependencies: + '@typescript-eslint/types': 8.62.1 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@6.0.3(vite@8.1.3(@types/node@24.13.2))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.3(@types/node@24.13.2) + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.40: {} + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.4: + dependencies: + baseline-browser-mapping: 2.10.40 + caniuse-lite: 1.0.30001800 + electron-to-chromium: 1.5.384 + node-releases: 2.0.50 + update-browserslist-db: 1.2.3(browserslist@4.28.4) + + caniuse-lite@1.0.30001800: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.384: {} + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@10.6.0): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 10.6.0 + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.3(eslint@10.6.0): + dependencies: + eslint: 10.6.0 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.6.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.7.0: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.50: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.9.4: {} + + punycode@2.3.1: {} + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + react-router@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie: 1.1.1 + react: 19.2.7 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + + react@19.2.7: {} + + rolldown@1.1.4: + dependencies: + '@oxc-project/types': 0.138.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.4 + '@rolldown/binding-darwin-arm64': 1.1.4 + '@rolldown/binding-darwin-x64': 1.1.4 + '@rolldown/binding-freebsd-x64': 1.1.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 + '@rolldown/binding-linux-arm64-gnu': 1.1.4 + '@rolldown/binding-linux-arm64-musl': 1.1.4 + '@rolldown/binding-linux-ppc64-gnu': 1.1.4 + '@rolldown/binding-linux-s390x-gnu': 1.1.4 + '@rolldown/binding-linux-x64-gnu': 1.1.4 + '@rolldown/binding-linux-x64-musl': 1.1.4 + '@rolldown/binding-openharmony-arm64': 1.1.4 + '@rolldown/binding-wasm32-wasi': 1.1.4 + '@rolldown/binding-win32-arm64-msvc': 1.1.4 + '@rolldown/binding-win32-x64-msvc': 1.1.4 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.62.1(eslint@10.6.0)(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0)(typescript@6.0.3))(eslint@10.6.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.62.1(eslint@10.6.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.62.1(eslint@10.6.0)(typescript@6.0.3) + eslint: 10.6.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + undici-types@7.18.2: {} + + update-browserslist-db@1.2.3(browserslist@4.28.4): + dependencies: + browserslist: 4.28.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-plugin-port-checker@1.0.2(vite@8.1.3(@types/node@24.13.2)): + dependencies: + vite: 8.1.3(@types/node@24.13.2) + + vite@8.1.3(@types/node@24.13.2): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.16 + rolldown: 1.1.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.2 + fsevents: 2.3.3 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons.svg b/web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/App.css b/web/src/App.css new file mode 100644 index 0000000..65cdf55 --- /dev/null +++ b/web/src/App.css @@ -0,0 +1,126 @@ +/* Reset & base */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f5f7; + color: #1a1a2e; +} + +#root { + min-height: 100vh; +} + +/* Shared card style */ +.card { + background: #fff; + border-radius: 8px; + padding: 20px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + margin-bottom: 16px; +} + +/* Shared table style */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.data-table thead { + background: #f8f9fa; +} + +.data-table th { + padding: 10px 12px; + text-align: left; + font-weight: 600; + color: #555; + border-bottom: 2px solid #e9ecef; +} + +.data-table td { + padding: 10px 12px; + border-bottom: 1px solid #f0f0f0; +} + +.data-table tbody tr:hover { + background: #f8f9ff; +} + +.td-empty { + text-align: center; + color: #aaa; + padding: 30px 12px !important; +} + +/* Shared pagination */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 12px 0 0; + font-size: 13px; + color: #666; +} + +.pagination button { + padding: 4px 12px; + border: 1px solid #ddd; + border-radius: 4px; + background: #fff; + cursor: pointer; + font-size: 12px; +} + +.pagination button:disabled { + opacity: 0.4; + cursor: default; +} + +/* Shared utility */ +.page-title { + margin: 0 0 24px; + font-size: 20px; + color: #1a1a2e; +} + +.page-loading { + padding: 40px; + color: #888; + text-align: center; +} + +.btn-primary { + padding: 10px 20px; + background: #e94560; + color: #fff; + border: none; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + transition: background 0.15s; +} + +.btn-primary:hover { + background: #d63850; +} + +.btn-primary:disabled { + background: #ccc; + cursor: not-allowed; +} + +.btn-sm { + width: auto; + padding: 8px 16px; +} + +.msg-success { color: #27ae60; font-size: 13px; margin: 8px 0; } +.msg-error { color: #c00; font-size: 13px; margin: 8px 0; } diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..98340e5 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,36 @@ +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom" +import Layout from "@/components/Layout" +import Login from "@/pages/Login" +import Dashboard from "@/pages/Dashboard" +import Artefacts from "@/pages/Artefacts" +import Deployments from "@/pages/Deployments" +import Users from "@/pages/Users" +import "@/App.css" + +function RequireAuth({ children }: { children: React.ReactNode }) { + const token = localStorage.getItem("token") + if (!token) return + return <>{children} +} + +export default function App() { + return ( + + + } /> + + + + }> + } /> + } /> + } /> + } /> + + } /> + + + ) +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..a74253b --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,70 @@ +const API_BASE = '/api/v1'; + +let token: string | null = localStorage.getItem('token'); + +export function setToken(t: string | null) { + token = t; + if (t) { + localStorage.setItem('token', t); + } else { + localStorage.removeItem('token'); + } +} + +export function getToken(): string | null { + return token; +} + +async function request(path: string, options: RequestInit = {}): Promise { + const headers: Record = { + ...(options.headers as Record), + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + // Only set Content-Type for non-GET requests with body + if (!(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } else { + delete headers['Content-Type']; // Let browser set multipart boundary + } + + const res = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + }); + + if (res.status === 401) { + setToken(null); + window.location.href = '/'; + throw new Error('Unauthorised'); + } + + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(body.error || `Request failed: ${res.status}`); + } + + return res.json(); +} + +export const api = { + get: (path: string) => request(path), + + post: (path: string, body?: unknown) => + request(path, { + method: 'POST', + body: body instanceof FormData ? body : JSON.stringify(body), + }), + + put: (path: string, body?: unknown) => + request(path, { + method: 'PUT', + body: JSON.stringify(body), + }), + + delete: (path: string) => + request(path, { method: 'DELETE' }), +}; diff --git a/web/src/assets/hero.png b/web/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/web/src/assets/hero.png differ diff --git a/web/src/assets/react.svg b/web/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/vite.svg b/web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/web/src/components/Layout.css b/web/src/components/Layout.css new file mode 100644 index 0000000..09d897e --- /dev/null +++ b/web/src/components/Layout.css @@ -0,0 +1,79 @@ +.app-layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 220px; + background: #1a1a2e; + color: #e0e0e0; + display: flex; + flex-direction: column; + padding: 0; + flex-shrink: 0; +} + +.sidebar-brand { + padding: 20px 16px; + border-bottom: 1px solid #16213e; +} + +.sidebar-brand h2 { + margin: 0; + font-size: 16px; + color: #e94560; + letter-spacing: 1px; +} + +.sidebar nav { + flex: 1; + padding: 12px 0; +} + +.nav-link { + display: block; + padding: 10px 20px; + color: #a0a0b8; + text-decoration: none; + font-size: 14px; + transition: background 0.15s, color 0.15s; +} + +.nav-link:hover { + background: #16213e; + color: #fff; +} + +.nav-link.active { + background: #e94560; + color: #fff; +} + +.sidebar-footer { + padding: 16px; + border-top: 1px solid #16213e; +} + +.btn-logout { + width: 100%; + padding: 8px; + background: transparent; + border: 1px solid #e94560; + color: #e94560; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + transition: background 0.15s, color 0.15s; +} + +.btn-logout:hover { + background: #e94560; + color: #fff; +} + +.content { + flex: 1; + padding: 24px 32px; + background: #f5f5f7; + overflow-y: auto; +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx new file mode 100644 index 0000000..78533df --- /dev/null +++ b/web/src/components/Layout.tsx @@ -0,0 +1,42 @@ +import { NavLink, useNavigate, Outlet } from 'react-router-dom'; +import { setToken } from '../api/client'; +import './Layout.css'; + +export default function Layout() { + const navigate = useNavigate(); + + function handleLogout() { + setToken(null); + navigate('/'); + } + + return ( +
+ +
+ +
+
+ ); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..0493199 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1 @@ +/* index.css — global reset is handled in App.css */ diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..578a7bd --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" +import "./index.css" +import App from "./App.tsx" + +createRoot(document.getElementById("root")!).render( + + + +) diff --git a/web/src/pages/Artefacts.css b/web/src/pages/Artefacts.css new file mode 100644 index 0000000..20d3a70 --- /dev/null +++ b/web/src/pages/Artefacts.css @@ -0,0 +1,50 @@ +.upload-section { + margin-bottom: 20px; +} + +.upload-section h3 { + margin: 0 0 12px; + font-size: 15px; +} + +.upload-form { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.upload-form input[type="text"] { + width: 180px; + padding: 8px 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 13px; +} + +.upload-form input[type="file"] { + font-size: 13px; +} + +.msg-success { color: #27ae60; font-size: 13px; margin: 8px 0 0; } +.msg-error { color: #c00; font-size: 13px; margin: 8px 0 0; } + +.btn-sm { + width: auto; + padding: 8px 16px; +} + +.badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + font-weight: 600; +} +.badge-delta { background: #fef3cd; color: #856404; } +.badge-full { background: #d4edda; color: #155724; } + +.hash-cell { + font-family: monospace; + font-size: 12px; + color: #666; +} diff --git a/web/src/pages/Artefacts.tsx b/web/src/pages/Artefacts.tsx new file mode 100644 index 0000000..4e42413 --- /dev/null +++ b/web/src/pages/Artefacts.tsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from "react" +import { api } from "../api/client" +import "./Artefacts.css" + +interface Artefact { + id: number + version: string + fileName: string + fileSize: number + sha256Hash: string + isDelta: boolean + deltaRef: string + createdAt: string +} + +const PAGE_SIZE = 20 + +export default function Artefacts() { + const [artefacts, setArtefacts] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + const [uploading, setUploading] = useState(false) + const [version, setVersion] = useState("") + const [file, setFile] = useState(null) + const [uploadMsg, setUploadMsg] = useState("") + + async function load() { + setLoading(true) + try { + const res = await api.get<{ data: Artefact[]; total: number }>( + `/artefacts?page=${page}&pageSize=${PAGE_SIZE}` + ) + setArtefacts(res.data) + setTotal(res.total) + } catch (err) { + console.error(err) + } finally { + setLoading(false) + } + } + + useEffect(() => { + load() + }, [page]) + + async function handleUpload(e: React.FormEvent) { + e.preventDefault() + if (!file || !version) return + + const form = new FormData() + form.append("version", version) + form.append("file", file) + + setUploading(true) + setUploadMsg("") + try { + await api.post("/artefacts", form) + setVersion("") + setFile(null) + setUploadMsg("Version uploaded successfully.") + load() + } catch (err) { + setUploadMsg("Upload failed: " + (err instanceof Error ? err.message : "unknown error")) + } finally { + setUploading(false) + } + } + + const totalPages = Math.ceil(total / PAGE_SIZE) + + return ( +
+

Versions

+ +
+

Create New Version

+
+ setVersion(e.target.value)} + required + /> + setFile(e.target.files?.[0] || null)} + required + /> + +
+ {uploadMsg && ( +

{uploadMsg}

+ )} +
+ +
+ + + + + + + + + + + + + {loading ? ( + + + + ) : artefacts.length === 0 ? ( + + + + ) : ( + artefacts.map((a) => ( + + + + + + + + + )) + )} + +
VersionFileSizeTypeSHA256Created
+ Loading... +
+ No versions yet. Upload the first one above. +
+ {a.version} + {a.fileName}{formatSize(a.fileSize)} + {a.isDelta ? ( + delta ({a.deltaRef}) + ) : ( + full + )} + + {a.sha256Hash.slice(0, 12)}... + {new Date(a.createdAt).toLocaleDateString()}
+ {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+
+ ) +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return bytes + " B" + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB" + return (bytes / (1024 * 1024)).toFixed(1) + " MB" +} diff --git a/web/src/pages/Dashboard.css b/web/src/pages/Dashboard.css new file mode 100644 index 0000000..81fb9db --- /dev/null +++ b/web/src/pages/Dashboard.css @@ -0,0 +1,37 @@ +.page-title { + margin: 0 0 24px; + font-size: 20px; + color: #1a1a2e; +} + +.page-loading { + padding: 40px; + color: #888; + text-align: center; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; +} + +.stat-card { + background: #fff; + border-radius: 8px; + padding: 20px; + border-top: 3px solid #ccc; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.stat-value { + font-size: 32px; + font-weight: 700; + color: #1a1a2e; +} + +.stat-label { + font-size: 13px; + color: #888; + margin-top: 4px; +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx new file mode 100644 index 0000000..527a43e --- /dev/null +++ b/web/src/pages/Dashboard.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react" +import { api } from "../api/client" +import "./Dashboard.css" + +interface Stats { + totalDevices: number + onlineDevices: number + activeDeployments: number + totalArtefacts: number +} + +export default function Dashboard() { + const [stats, setStats] = useState({ + totalDevices: 0, + onlineDevices: 0, + activeDeployments: 0, + totalArtefacts: 0, + }) + const [loading, setLoading] = useState(true) + + useEffect(() => { + async function load() { + try { + const [devices, artefacts, deployments] = await Promise.all([ + api.get<{ total: number; data: { status: string }[] }>("/devices?pageSize=1"), + api.get<{ total: number }>("/artefacts?pageSize=1"), + api.get<{ total: number; data: { status: string }[] }>("/deployments?pageSize=100"), + ]) + + const online = devices.data + ? devices.data.filter((d: { status: string }) => d.status === "online").length + : 0 + const active = deployments.data + ? deployments.data.filter((d: { status: string }) => d.status === "active").length + : 0 + + setStats({ + totalDevices: devices.total, + onlineDevices: online, + activeDeployments: active, + totalArtefacts: artefacts.total, + }) + } catch (err) { + console.error("Failed to load stats:", err) + } finally { + setLoading(false) + } + } + load() + }, []) + + if (loading) return
Loading...
+ + const cards = [ + { label: "Total Devices", value: stats.totalDevices, colour: "#4a90d9" }, + { label: "Online Devices", value: stats.onlineDevices, colour: "#27ae60" }, + { label: "Active Deployments", value: stats.activeDeployments, colour: "#e67e22" }, + { label: "Artefact Versions", value: stats.totalArtefacts, colour: "#8e44ad" }, + ] + + return ( +
+

Dashboard

+
+ {cards.map((c) => ( +
+
{c.value}
+
{c.label}
+
+ ))} +
+
+ ) +} diff --git a/web/src/pages/Deployments.css b/web/src/pages/Deployments.css new file mode 100644 index 0000000..75d8bc7 --- /dev/null +++ b/web/src/pages/Deployments.css @@ -0,0 +1,56 @@ +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.page-header .page-title { margin-bottom: 0; } + +.form-section { margin-bottom: 20px; } +.form-section h3 { margin: 0 0 12px; font-size: 15px; } + +.dep-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.dep-form label { + display: block; + font-size: 13px; + color: #333; +} + +.dep-form input, +.dep-form select { + display: block; + width: 100%; + margin-top: 3px; + padding: 7px 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 13px; + box-sizing: border-box; +} + +.badge-draft { background: #e2e3e5; color: #383d41; } +.badge-active { background: #d4edda; color: #155724; } +.badge-paused { background: #fff3cd; color: #856404; } +.badge-completed { background: #d4edda; color: #155724; } +.badge-cancelled { background: #f8d7da; color: #721c24; } + +.actions-cell { white-space: nowrap; } + +.btn-action { + padding: 4px 12px; + border: none; + border-radius: 3px; + font-size: 12px; + cursor: pointer; + background: #27ae60; + color: #fff; +} + +.btn-action:hover { opacity: 0.85; } +.btn-pause { background: #e67e22; } diff --git a/web/src/pages/Deployments.tsx b/web/src/pages/Deployments.tsx new file mode 100644 index 0000000..879995b --- /dev/null +++ b/web/src/pages/Deployments.tsx @@ -0,0 +1,266 @@ +import { type FormEvent, useEffect, useState } from "react" +import { api } from "../api/client" +import "./Deployments.css" + +interface Deployment { + id: number + artefactId: number + artefactVersion: string + name: string + targetType: string + rolloutStrategy: string + maxConcurrent: number + status: string + createdAt: string +} + +interface Artefact { + id: number + version: string +} + +export default function Deployments() { + const [deployments, setDeployments] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + + // Create form + const [showForm, setShowForm] = useState(false) + const [artefacts, setArtefacts] = useState([]) + const [name, setName] = useState("") + const [artefactId, setArtefactId] = useState("") + const [targetType, setTargetType] = useState("all") + const [targetSpec, setTargetSpec] = useState("") + const [rolloutStrategy, setRolloutStrategy] = useState("immediate") + const [maxConcurrent, setMaxConcurrent] = useState(50) + const [creating, setCreating] = useState(false) + const [msg, setMsg] = useState("") + + async function load() { + setLoading(true) + try { + const res = await api.get<{ data: Deployment[]; total: number }>( + `/deployments?page=${page}&pageSize=20` + ) + setDeployments(res.data) + setTotal(res.total) + } catch (err) { + console.error(err) + } finally { + setLoading(false) + } + } + + useEffect(() => { + load() + }, [load, page]) + + async function loadArtefacts() { + try { + const res = await api.get<{ data: Artefact[] }>("/artefacts?pageSize=200") + setArtefacts(res.data) + } catch (err) { + console.error(err) + } + } + + async function handleCreate(e: FormEvent) { + e.preventDefault() + setCreating(true) + setMsg("") + try { + await api.post("/deployments", { + artefactId: parseInt(artefactId), + name, + targetType, + targetSpec: targetSpec || "{}", + rolloutStrategy, + maxConcurrent, + }) + setShowForm(false) + setName("") + setArtefactId("") + setTargetSpec("") + setMsg("Deployment created. Activate it to begin rollout.") + load() + } catch (err) { + setMsg("Failed: " + (err instanceof Error ? err.message : "unknown")) + } finally { + setCreating(false) + } + } + + async function activate(id: number) { + try { + await api.post(`/deployments/${id}/activate`) + load() + } catch (err) { + alert("Failed to activate") + } + } + + async function pause(id: number) { + try { + await api.post(`/deployments/${id}/pause`) + load() + } catch (err) { + alert("Failed to pause") + } + } + + const totalPages = Math.ceil(total / 20) + + return ( +
+
+

Deployments

+ +
+ + {msg &&

{msg}

} + + {showForm && ( +
+

Create Deployment

+
+ + + + {targetType !== "all" && ( + + )} + + + +
+
+ )} + +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : deployments.length === 0 ? ( + + + + ) : ( + deployments.map((d) => ( + + + + + + + + + + )) + )} + +
NameVersionTargetStrategyStatusCreatedActions
+ Loading... +
+ No deployments yet. +
+ {d.name} + {d.artefactVersion}{d.targetType}{d.rolloutStrategy} + {d.status} + {new Date(d.createdAt).toLocaleDateString()} + {d.status === "draft" && ( + + )} + {d.status === "active" && ( + + )} +
+ {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+
+ ) +} diff --git a/web/src/pages/Login.css b/web/src/pages/Login.css new file mode 100644 index 0000000..ba2e0c9 --- /dev/null +++ b/web/src/pages/Login.css @@ -0,0 +1,81 @@ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #1a1a2e; +} + +.login-form { + background: #fff; + padding: 40px; + border-radius: 8px; + width: 360px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.login-form h1 { + margin: 0 0 4px; + font-size: 22px; + color: #e94560; +} + +.login-subtitle { + margin: 0 0 24px; + color: #888; + font-size: 13px; +} + +.login-error { + background: #fff0f0; + color: #c00; + padding: 8px 12px; + border-radius: 4px; + margin-bottom: 16px; + font-size: 13px; +} + +.login-form label { + display: block; + margin-bottom: 16px; + font-size: 13px; + color: #333; +} + +.login-form input { + display: block; + width: 100%; + margin-top: 4px; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; +} + +.login-form input:focus { + outline: none; + border-color: #e94560; + box-shadow: 0 0 0 2px rgba(233, 69, 96, 0.15); +} + +.btn-primary { + width: 100%; + padding: 10px; + background: #e94560; + color: #fff; + border: none; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + transition: background 0.15s; +} + +.btn-primary:hover { + background: #d63850; +} + +.btn-primary:disabled { + background: #ccc; + cursor: not-allowed; +} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..6043fee --- /dev/null +++ b/web/src/pages/Login.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api, setToken } from '../api/client'; +import './Login.css'; + +export default function Login() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const res = await api.post<{ token: string }>('/auth/login', { username, password }); + setToken(res.token); + navigate('/dashboard'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed'); + } finally { + setLoading(false); + } + } + + return ( +
+
+

OTA Manager

+

Sign in to manage updates

+ + {error &&
{error}
} + + + + + + +
+
+ ); +} diff --git a/web/src/pages/Users.css b/web/src/pages/Users.css new file mode 100644 index 0000000..753c2ab --- /dev/null +++ b/web/src/pages/Users.css @@ -0,0 +1,32 @@ +.user-form { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.user-form input, +.user-form select { + padding: 7px 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 13px; +} + +.pw-inline { + display: flex; + gap: 6px; + align-items: center; +} + +.pw-inline input { + padding: 4px 8px; + border: 1px solid #ddd; + border-radius: 3px; + font-size: 12px; +} + +.btn-danger { background: #c0392b; } +.btn-danger:hover { background: #a93226; } +.btn-cancel { background: #888; } +.btn-cancel:hover { background: #666; } diff --git a/web/src/pages/Users.tsx b/web/src/pages/Users.tsx new file mode 100644 index 0000000..ed5f48e --- /dev/null +++ b/web/src/pages/Users.tsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api/client'; +import './Users.css'; + +interface AdminUser { + id: number; + username: string; + role: string; + createdAt: string; +} + +export default function Users() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + + // Create form + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState('admin'); + const [creating, setCreating] = useState(false); + const [msg, setMsg] = useState(''); + + // Change password state + const [changingId, setChangingId] = useState(null); + const [newPassword, setNewPassword] = useState(''); + + async function load() { + setLoading(true); + try { + const res = await api.get<{ data: AdminUser[] }>('/admin/users'); + setUsers(res.data); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + } + + useEffect(() => { load(); }, []); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setCreating(true); + setMsg(''); + try { + await api.post('/admin/users', { username, password, role }); + setUsername(''); + setPassword(''); + setRole('admin'); + setMsg('User created.'); + load(); + } catch (err) { + setMsg('Failed: ' + (err instanceof Error ? err.message : 'unknown')); + } finally { + setCreating(false); + } + } + + async function handleDelete(id: number, uname: string) { + if (!confirm(`Delete user "${uname}"?`)) return; + try { + await api.delete(`/admin/users/${id}`); + load(); + } catch (err) { + alert('Failed to delete'); + } + } + + async function handleChangePassword(id: number) { + if (!newPassword) return; + try { + await api.put(`/admin/users/${id}/password`, { newPassword }); + setChangingId(null); + setNewPassword(''); + setMsg('Password changed.'); + } catch (err) { + alert('Failed to change password'); + } + } + + return ( +
+

Admin Users

+ + {msg &&

{msg}

} + +
+

Add User

+
+ setUsername(e.target.value)} required /> + setPassword(e.target.value)} required /> + + +
+
+ +
+ + + + + + + + + + + {loading ? ( + + ) : users.length === 0 ? ( + + ) : users.map(u => ( + + + + + + + ))} + +
UsernameRoleCreatedActions
Loading...
No users.
{u.username}{u.role}{new Date(u.createdAt).toLocaleDateString()} + {changingId === u.id ? ( + + setNewPassword(e.target.value)} + size={16} + /> + + + + ) : ( + <> + + + + )} +
+
+
+ ); +} diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..7eb8d9b --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..013f7b2 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, + server: { + proxy: { + '/api': 'http://localhost:8080', + }, + }, + build: { + outDir: 'dist', + }, +})