From b4509d12e7b53a1608ca1dfae3513a80df54fc43 Mon Sep 17 00:00:00 2001 From: Pacerino Date: Mon, 15 Jun 2026 14:17:34 +0200 Subject: [PATCH 1/6] [Backend][Frontend] Add host plugins with module config and schema-driven UI Introduce per-host plugin support: Caddy module discovery, module config endpoints, plugin schemas (basicauth, cloudflare) and the host plugins API. On the frontend, add the Plugins page, plugin/host plugin dialogs and schema-driven form fields. --- backend/embed/caddy/host.hbs | 7 + backend/internal/api/handler/caddy.go | 236 ++++ backend/internal/api/handler/hostplugins.go | 141 ++ backend/internal/api/handler/hosts.go | 46 +- backend/internal/api/router.go | 18 + backend/internal/auth/keys.go | 16 +- backend/internal/caddy/api.go | 26 + backend/internal/caddy/caddy.go | 48 +- backend/internal/caddy/json.go | 26 +- backend/internal/caddy/moduleconfig.go | 49 + backend/internal/caddy/modules.go | 95 ++ backend/internal/caddy/schema/basicauth.go | 61 + backend/internal/caddy/schema/cloudflare.go | 39 + backend/internal/caddy/schema/schema.go | 196 +++ backend/internal/database/models.go | 80 +- backend/internal/database/sqlite.go | 2 +- frontend/package-lock.json | 1226 ++++++++++++++++- frontend/package.json | 11 +- frontend/src/App.tsx | 2 + frontend/src/components/HostDialog.tsx | 3 + .../src/components/HostPluginsSection.tsx | 181 +++ frontend/src/components/Layout.tsx | 35 +- frontend/src/components/PluginDialog.tsx | 165 +++ frontend/src/components/SchemaFields.tsx | 71 + frontend/src/lib/types.ts | 59 + frontend/src/pages/Plugins.tsx | 207 +++ 26 files changed, 3012 insertions(+), 34 deletions(-) create mode 100644 backend/internal/api/handler/caddy.go create mode 100644 backend/internal/api/handler/hostplugins.go create mode 100644 backend/internal/caddy/moduleconfig.go create mode 100644 backend/internal/caddy/modules.go create mode 100644 backend/internal/caddy/schema/basicauth.go create mode 100644 backend/internal/caddy/schema/cloudflare.go create mode 100644 backend/internal/caddy/schema/schema.go create mode 100644 frontend/src/components/HostPluginsSection.tsx create mode 100644 frontend/src/components/PluginDialog.tsx create mode 100644 frontend/src/components/SchemaFields.tsx create mode 100644 frontend/src/pages/Plugins.tsx diff --git a/backend/embed/caddy/host.hbs b/backend/embed/caddy/host.hbs index ea1e932..af881ae 100644 --- a/backend/embed/caddy/host.hbs +++ b/backend/embed/caddy/host.hbs @@ -1,4 +1,11 @@ {{host.domains}} { +{{#if BasicAuth}} + basic_auth { +{{#each BasicAuth}} + {{User}} {{Hash}} +{{/each}} + } +{{/if}} {{#if host.upstreams}} {{#if host.matcher}} reverse_proxy {{host.matcher}}{{#each host.upstreams}} {{backend}}{{/each}} diff --git a/backend/internal/api/handler/caddy.go b/backend/internal/api/handler/caddy.go new file mode 100644 index 0000000..0b221ec --- /dev/null +++ b/backend/internal/api/handler/caddy.go @@ -0,0 +1,236 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strings" + + h "github.com/Pacerino/CaddyProxyManager/internal/api/http" + "github.com/Pacerino/CaddyProxyManager/internal/caddy" + "github.com/Pacerino/CaddyProxyManager/internal/caddy/schema" + "github.com/Pacerino/CaddyProxyManager/internal/config" + "github.com/Pacerino/CaddyProxyManager/internal/database" + "github.com/Pacerino/CaddyProxyManager/internal/jobqueue" + + "github.com/go-chi/chi/v5" +) + +// modulesResponse is the payload returned by GetCaddyModules. +type modulesResponse struct { + // Modules is the flat, sorted list of every module the build reports. + Modules []caddy.Module `json:"modules"` + // Plugins is the subset that is not part of stock Caddy, i.e. the + // user-supplied plugins this build was compiled with. + Plugins []caddy.Module `json:"plugins"` +} + +// GetCaddyModules returns the modules reported by the configured Caddy binary +// via `caddy list-modules --json`. +// Route: GET /caddy/modules +func (s Handler) GetCaddyModules() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + modules, err := caddy.ListModules() + if err != nil { + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + return + } + + plugins := make([]caddy.Module, 0) + for _, m := range modules { + if !m.Standard { + plugins = append(plugins, m) + } + } + + h.ResultResponseJSON(w, r, http.StatusOK, modulesResponse{ + Modules: modules, + Plugins: plugins, + }) + } +} + +// GetCaddyConfig returns the live Caddy configuration read through the admin +// API. Only available in API mode. An optional ?path= query selects a subtree. +// Route: GET /caddy/config +func (s Handler) GetCaddyConfig() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + provider, ok := apiProviderOrError(w, r) + if !ok { + return + } + + var raw json.RawMessage + if err := provider.GetConfig(r.URL.Query().Get("path"), &raw); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadGateway, err.Error(), nil) + return + } + if len(raw) == 0 { + raw = json.RawMessage("null") + } + h.ResultResponseJSON(w, r, http.StatusOK, raw) + } +} + +// GetCaddySchemas returns the global-scope typed configuration schemas. +// Modules without a schema are configured through the raw JSON fallback. +// Route: GET /caddy/schemas +func (s Handler) GetCaddySchemas() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + h.ResultResponseJSON(w, r, http.StatusOK, schema.WithScope(schema.ScopeGlobal)) + } +} + +// GetModuleConfigs lists stored module configurations. +// Route: GET /caddy/module-configs +func (s Handler) GetModuleConfigs() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + var configs []database.ModuleConfig + if err := s.DB.Find(&configs).Error; err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + h.ResultResponseJSON(w, r, http.StatusOK, configs) + } +} + +// moduleConfigRequest is the body accepted by SetModuleConfig. Either Config +// (raw JSON fallback) or Values (typed form for a known schema) must be set. +type moduleConfigRequest struct { + ModuleID string `json:"moduleId"` + Path string `json:"path,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Config json.RawMessage `json:"config,omitempty"` + Values map[string]any `json:"values,omitempty"` +} + +// SetModuleConfig creates or updates the configuration for a module and +// applies it to the live Caddy config. When a schema is registered for the +// module, Values are validated and rendered; otherwise raw Config is used. +// Route: PUT /caddy/module-configs +func (s Handler) SetModuleConfig() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + provider, ok := apiProviderOrError(w, r) + if !ok { + return + } + + var req moduleConfigRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + if req.ModuleID == "" { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "moduleId is required", nil) + return + } + + path := req.Path + var rawConfig json.RawMessage + + if sc, found := schema.Get(req.ModuleID); found { + // Typed form: validate + render through the schema. + built, err := sc.Build(req.Values) + if err != nil { + if ve, isVE := err.(*schema.ValidationError); isVE { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "schema validation failed", ve.Fields) + return + } + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + rawConfig = built + if path == "" { + path = sc.Path + } + } else { + // Raw JSON fallback. + if len(req.Config) == 0 { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "config is required for modules without a schema", nil) + return + } + if !json.Valid(req.Config) { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "config is not valid JSON", nil) + return + } + rawConfig = req.Config + } + + if path == "" { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "path is required", nil) + return + } + + mc := database.ModuleConfig{ + ModuleID: req.ModuleID, + Path: path, + Config: database.JSON(rawConfig), + Enabled: true, + } + if req.Enabled != nil { + mc.Enabled = *req.Enabled + } + + // Upsert by ModuleID. + var existing database.ModuleConfig + if err := s.DB.Where("module_id = ?", req.ModuleID).First(&existing).Error; err == nil { + mc.ID = existing.ID + } + if err := s.DB.Save(&mc).Error; err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + if err := jobqueue.AddJob(jobqueue.Job{ + Name: "CaddyApplyModuleConfig", + Action: func() error { return provider.ApplyModuleConfig(mc) }, + }); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + h.ResultResponseJSON(w, r, http.StatusOK, mc) + } +} + +// DeleteModuleConfig removes a module configuration and its live config. +// Route: DELETE /caddy/module-configs/{id} +func (s Handler) DeleteModuleConfig() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + provider, ok := apiProviderOrError(w, r) + if !ok { + return + } + id := chi.URLParam(r, "id") + + var mc database.ModuleConfig + if err := s.DB.Where("id = ?", id).First(&mc).Error; err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + if err := jobqueue.AddJob(jobqueue.Job{ + Name: "CaddyRemoveModuleConfig", + Action: func() error { return provider.RemoveModuleConfig(mc) }, + }); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + if err := s.DB.Delete(&database.ModuleConfig{}, mc.ID).Error; err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + h.ResultResponseJSON(w, r, http.StatusOK, true) + } +} + +// apiProviderOrError resolves the API provider or writes an error response and +// returns false. Module configuration is only supported in API mode for now. +func apiProviderOrError(w http.ResponseWriter, r *http.Request) (*caddy.APIProvider, bool) { + if strings.ToLower(config.Configuration.Caddy.Mode) != "api" { + h.ResultErrorJSON(w, r, http.StatusBadRequest, + "caddy configuration via the admin API is only available when CPM_CADDY_MODE=api", nil) + return nil, false + } + return caddy.NewAPIProvider(), true +} diff --git a/backend/internal/api/handler/hostplugins.go b/backend/internal/api/handler/hostplugins.go new file mode 100644 index 0000000..67f5c63 --- /dev/null +++ b/backend/internal/api/handler/hostplugins.go @@ -0,0 +1,141 @@ +package handler + +import ( + "encoding/json" + "net/http" + + h "github.com/Pacerino/CaddyProxyManager/internal/api/http" + "github.com/Pacerino/CaddyProxyManager/internal/caddy" + "github.com/Pacerino/CaddyProxyManager/internal/caddy/schema" + "github.com/Pacerino/CaddyProxyManager/internal/database" + "github.com/Pacerino/CaddyProxyManager/internal/jobqueue" +) + +// GetHostScopedSchemas returns the typed schemas for plugins configurable per +// host. The frontend filters these against the host's Caddy build. +// Route: GET /caddy/host-schemas +func (s Handler) GetHostScopedSchemas() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + h.ResultResponseJSON(w, r, http.StatusOK, schema.WithScope(schema.ScopeHost)) + } +} + +// hostPluginRequest is the body accepted by SetHostPlugin. +type hostPluginRequest struct { + ModuleID string `json:"moduleId"` + Enabled *bool `json:"enabled,omitempty"` + Values map[string]any `json:"values"` +} + +// SetHostPlugin creates or updates a per-host plugin configuration and +// re-applies the host's route. +// Route: PUT /hosts/{hostID}/plugins +func (s Handler) SetHostPlugin() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + hostID, err := getURLParamInt(r, "hostID") + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + var req hostPluginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + sc, found := schema.Get(req.ModuleID) + if !found || !sc.HasScope(schema.ScopeHost) { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "module is not configurable per host", nil) + return + } + + handler, err := sc.BuildHandler(req.Values) + if err != nil { + if ve, ok := err.(*schema.ValidationError); ok { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "schema validation failed", ve.Fields) + return + } + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + handlerJSON, err := json.Marshal(handler) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + return + } + + plugin := database.HostPlugin{ + HostID: uint(hostID), + ModuleID: req.ModuleID, + Handler: database.JSON(handlerJSON), + Enabled: true, + } + if req.Enabled != nil { + plugin.Enabled = *req.Enabled + } + + // Upsert by (host, module). + var existing database.HostPlugin + if err := s.DB.Where("host_id = ? AND module_id = ?", hostID, req.ModuleID).First(&existing).Error; err == nil { + plugin.ID = existing.ID + } + if err := s.DB.Save(&plugin).Error; err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + if err := s.reapplyHost(uint(hostID)); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + h.ResultResponseJSON(w, r, http.StatusOK, plugin) + } +} + +// DeleteHostPlugin removes a per-host plugin and re-applies the host's route. +// Route: DELETE /hosts/{hostID}/plugins/{pluginID} +func (s Handler) DeleteHostPlugin() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + hostID, err := getURLParamInt(r, "hostID") + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + pluginID, err := getURLParamInt(r, "pluginID") + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + if err := s.DB.Where("host_id = ?", hostID).Delete(&database.HostPlugin{}, pluginID).Error; err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + if err := s.reapplyHost(uint(hostID)); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + h.ResultResponseJSON(w, r, http.StatusOK, true) + } +} + +// reapplyHost reloads the host with its associations and re-writes its +// configuration through the configured provider via the job queue. +func (s Handler) reapplyHost(hostID uint) error { + var host database.Host + if err := s.DB.Where("id = ?", hostID).Preload("Upstreams").Preload("Plugins").First(&host).Error; err != nil { + return err + } + provider, err := caddy.GetProvider() + if err != nil { + return err + } + return jobqueue.AddJob(jobqueue.Job{ + Name: "CaddyConfigureHost", + Action: func() error { return provider.WriteHost(host) }, + }) +} diff --git a/backend/internal/api/handler/hosts.go b/backend/internal/api/handler/hosts.go index 4e85936..0b4352d 100644 --- a/backend/internal/api/handler/hosts.go +++ b/backend/internal/api/handler/hosts.go @@ -17,7 +17,7 @@ import ( func (s Handler) GetHosts() func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { var hosts []database.Host - if err := s.DB.Preload("Upstreams").Find(&hosts).Error; err != nil { + if err := s.DB.Preload("Upstreams").Preload("Plugins").Find(&hosts).Error; err != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) } h.ResultResponseJSON(w, r, http.StatusOK, hosts) @@ -36,7 +36,7 @@ func (s Handler) GetHost() func(http.ResponseWriter, *http.Request) { return } - if err = s.DB.Where("id = ?", hostID).Preload("Upstreams").First(&host).Error; err != nil { + if err = s.DB.Where("id = ?", hostID).Preload("Upstreams").Preload("Plugins").First(&host).Error; err != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) } else { h.ResultResponseJSON(w, r, http.StatusOK, host) @@ -71,11 +71,10 @@ func (s Handler) CreateHost() func(http.ResponseWriter, *http.Request) { return } + created := newHost if err := jobqueue.AddJob(jobqueue.Job{ - Name: "CaddyConfigureHost", - Action: func() error { - return provider.WriteHost(newHost) - }, + Name: "CaddyConfigureHost", + Action: func() error { return provider.WriteHost(created) }, }); err != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) return @@ -106,23 +105,40 @@ func (s Handler) UpdateHost() func(http.ResponseWriter, *http.Request) { return } - if err := jobqueue.AddJob(jobqueue.Job{ - Name: "CaddyConfigureHost", - Action: func() error { - return provider.WriteHost(newHost) - }, + // Persist the host fields and replace its upstreams. Replace deletes + // upstreams that are no longer present and inserts the new set, so + // repeated edits don't accumulate duplicates. + if err := s.DB.Transaction(func(tx *gorm.DB) error { + upstreams := newHost.Upstreams + newHost.Upstreams = nil + if err := tx.Model(&database.Host{}). + Where("id = ?", newHost.ID). + Updates(map[string]any{"domains": newHost.Domains, "matcher": newHost.Matcher}).Error; err != nil { + return err + } + return tx.Model(&newHost).Association("Upstreams").Replace(upstreams) }); err != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) return } - result := s.DB.Session(&gorm.Session{FullSaveAssociations: true}).Save(&newHost) - if result.Error != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, result.Error.Error(), nil) + // Re-read the persisted host (with associations) and apply that + // snapshot to Caddy. Reading a fresh copy avoids sharing the request's + // mutable struct with the async job goroutine. + var persisted database.Host + if err := s.DB.Where("id = ?", newHost.ID).Preload("Upstreams").Preload("Plugins").First(&persisted).Error; err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + if err := jobqueue.AddJob(jobqueue.Job{ + Name: "CaddyConfigureHost", + Action: func() error { return provider.WriteHost(persisted) }, + }); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) return } - h.ResultResponseJSON(w, r, http.StatusOK, newHost) + h.ResultResponseJSON(w, r, http.StatusOK, persisted) } } diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 1ae29ec..3b42e25 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -45,6 +45,24 @@ func generateRoutes(r chi.Router, h *handler.Handler) chi.Router { r.Get("/{hostID:[0-9]+}", h.GetHost()) // Get specific Host by ID r.Delete("/{hostID:[0-9]+}", h.DeleteHost()) // Delete Host by ID r.Put("/", h.UpdateHost()) // Update Host by ID + + // Per-host plugin configuration. + r.Put("/{hostID:[0-9]+}/plugins", h.SetHostPlugin()) // Create/update a host plugin + r.Delete("/{hostID:[0-9]+}/plugins/{pluginID:[0-9]+}", h.DeleteHostPlugin()) // Remove a host plugin + }) + + //Caddy (modules, schemas & live config) + r.With(middleware.Enforce()).Route("/caddy", func(r chi.Router) { + r.Get("/modules", h.GetCaddyModules()) // List modules/plugins of the caddy build + r.Get("/schemas", h.GetCaddySchemas()) // Global-scope config schemas + r.Get("/host-schemas", h.GetHostScopedSchemas()) // Host-scope config schemas + r.Get("/config", h.GetCaddyConfig()) // Read live caddy config (api mode) + + r.Route("/module-configs", func(r chi.Router) { + r.Get("/", h.GetModuleConfigs()) // List stored module configs + r.Put("/", h.SetModuleConfig()) // Create/update + apply a module config + r.Delete("/{id:[0-9]+}", h.DeleteModuleConfig()) // Delete a module config + }) }) //Auth diff --git a/backend/internal/auth/keys.go b/backend/internal/auth/keys.go index ca83a2c..d07feec 100644 --- a/backend/internal/auth/keys.go +++ b/backend/internal/auth/keys.go @@ -4,6 +4,7 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" + "errors" "fmt" "io" "os" @@ -58,12 +59,13 @@ func GetPrivateKey() (*rsa.PrivateKey, error) { // LoadPemPrivateKey reads a key from a PEM encoded string and returns a private key func LoadPemPrivateKey(content []byte) (*rsa.PrivateKey, error) { - var key *rsa.PrivateKey data, _ := pem.Decode(content) - var err error - key, err = x509.ParsePKCS1PrivateKey(data.Bytes) + if data == nil { + return nil, errors.New("no PEM data found in private key") + } + key, err := x509.ParsePKCS1PrivateKey(data.Bytes) if err != nil { - return key, err + return nil, err } return key, nil } @@ -90,11 +92,13 @@ func GetPublicKey() (*rsa.PublicKey, error) { // LoadPemPublicKey reads a key from a PEM encoded string and returns a public key func LoadPemPublicKey(content []byte) (*rsa.PublicKey, error) { - var key *rsa.PublicKey data, _ := pem.Decode(content) + if data == nil { + return nil, errors.New("no PEM data found in public key") + } publicKeyFileImported, err := x509.ParsePKCS1PublicKey(data.Bytes) if err != nil { - return key, err + return nil, err } return publicKeyFileImported, nil diff --git a/backend/internal/caddy/api.go b/backend/internal/caddy/api.go index c90a3da..408e596 100644 --- a/backend/internal/caddy/api.go +++ b/backend/internal/caddy/api.go @@ -56,6 +56,32 @@ func (p *APIProvider) WriteHost(h database.Host) error { return p.do(http.MethodPost, path, route, nil) } +// GetConfig returns the live Caddy configuration at the given path. Pass an +// empty path for the full config. The result is decoded into out, which is +// typically a *json.RawMessage or *map[string]any. +func (p *APIProvider) GetConfig(path string, out any) error { + full := "/config/" + strings.TrimPrefix(strings.TrimPrefix(path, "/config/"), "/") + status, data, err := p.request(http.MethodGet, full, nil) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return fmt.Errorf("caddy admin GET %s returned %d: %s", full, status, string(data)) + } + if out == nil || len(data) == 0 { + return nil + } + return json.Unmarshal(data, out) +} + +// PatchConfig merges body into the config at path using the admin API. Caddy's +// PATCH replaces the value at the addressed path, so callers should send the +// full object they want present at that path. +func (p *APIProvider) PatchConfig(path string, body any) error { + full := "/config/" + strings.TrimPrefix(strings.TrimPrefix(path, "/config/"), "/") + return p.do(http.MethodPatch, full, body, nil) +} + // RemoveHost deletes the route for a host. func (p *APIProvider) RemoveHost(hostID int) error { id := hostRouteID(uint(hostID)) diff --git a/backend/internal/caddy/caddy.go b/backend/internal/caddy/caddy.go index 5de7887..9235dcb 100644 --- a/backend/internal/caddy/caddy.go +++ b/backend/internal/caddy/caddy.go @@ -1,6 +1,7 @@ package caddy import ( + "encoding/json" "fmt" "os" @@ -12,6 +13,11 @@ import ( "github.com/aymerick/raymond" ) +// jsonUnmarshal unmarshals a database.JSON value. +func jsonUnmarshal(data database.JSON, v any) error { + return json.Unmarshal(data, v) +} + // CaddyfileProvider applies host configuration by rendering a per-host // Caddyfile snippet into the data folder and reloading Caddy. type CaddyfileProvider struct{} @@ -19,6 +25,43 @@ type CaddyfileProvider struct{} type hostEntity struct { Host database.Host LogPath string + // BasicAuth carries username/hash pairs derived from the host's + // authentication plugin so the Caddyfile template can render a + // basic_auth directive. + BasicAuth []basicAuthAccount +} + +type basicAuthAccount struct { + User string + Hash string +} + +// basicAuthAccounts extracts username/hash pairs from a host's enabled +// authentication plugins for Caddyfile rendering. +func basicAuthAccounts(h database.Host) []basicAuthAccount { + var out []basicAuthAccount + for _, p := range h.Plugins { + if !p.Enabled || p.ModuleID != "http.handlers.authentication" || len(p.Handler) == 0 { + continue + } + var handler struct { + Providers struct { + HTTPBasic struct { + Accounts []struct { + Username string `json:"username"` + Password string `json:"password"` + } `json:"accounts"` + } `json:"http_basic"` + } `json:"providers"` + } + if err := jsonUnmarshal(p.Handler, &handler); err != nil { + continue + } + for _, a := range handler.Providers.HTTPBasic.Accounts { + out = append(out, basicAuthAccount{User: a.Username, Hash: a.Password}) + } + } + return out } func hostConfigPath(hostID uint) string { @@ -28,8 +71,9 @@ func hostConfigPath(hostID uint) string { // WriteHost renders the host template and writes it to the config folder. func (p *CaddyfileProvider) WriteHost(h database.Host) error { data := &hostEntity{ - Host: h, - LogPath: fmt.Sprintf("%s/host_%d.log", config.Configuration.LogFolder, h.ID), + Host: h, + LogPath: fmt.Sprintf("%s/host_%d.log", config.Configuration.LogFolder, h.ID), + BasicAuth: basicAuthAccounts(h), } filename := hostConfigPath(h.ID) // Read Template from Embed FS diff --git a/backend/internal/caddy/json.go b/backend/internal/caddy/json.go index 478adaa..9fbf4e6 100644 --- a/backend/internal/caddy/json.go +++ b/backend/internal/caddy/json.go @@ -1,6 +1,7 @@ package caddy import ( + "encoding/json" "fmt" "strings" @@ -25,14 +26,18 @@ func buildRoute(h database.Host) map[string]any { upstreams = append(upstreams, map[string]any{"dial": u.Backend}) } - handler := map[string]any{ + proxy := map[string]any{ "handler": "reverse_proxy", "upstreams": upstreams, } + // Host plugin handlers (e.g. authentication) run before the reverse proxy. + handle := pluginHandlers(h) + handle = append(handle, proxy) + route := map[string]any{ "@id": hostRouteID(h.ID), - "handle": []map[string]any{handler}, + "handle": handle, "match": []map[string]any{{"host": hosts}}, "terminal": true, } @@ -40,6 +45,23 @@ func buildRoute(h database.Host) map[string]any { return route } +// pluginHandlers renders the enabled host plugin handlers for a host, in a +// stable order. Invalid or empty handler JSON is skipped. +func pluginHandlers(h database.Host) []map[string]any { + handlers := make([]map[string]any, 0, len(h.Plugins)) + for _, p := range h.Plugins { + if !p.Enabled || len(p.Handler) == 0 { + continue + } + var handler map[string]any + if err := json.Unmarshal(p.Handler, &handler); err != nil || len(handler) == 0 { + continue + } + handlers = append(handlers, handler) + } + return handlers +} + // splitDomains turns a space separated domain list into a slice. func splitDomains(domains string) []string { fields := strings.Fields(domains) diff --git a/backend/internal/caddy/moduleconfig.go b/backend/internal/caddy/moduleconfig.go new file mode 100644 index 0000000..f0dc6a3 --- /dev/null +++ b/backend/internal/caddy/moduleconfig.go @@ -0,0 +1,49 @@ +package caddy + +import ( + "encoding/json" + + "github.com/Pacerino/CaddyProxyManager/internal/database" +) + +// ApplyModuleConfig patches a module configuration fragment into the live +// Caddy config via the admin API. When the fragment is disabled it is removed +// instead. Only supported in API mode (callers must use APIProvider). +func (p *APIProvider) ApplyModuleConfig(mc database.ModuleConfig) error { + if !mc.Enabled { + return p.RemoveModuleConfig(mc) + } + var body any + if len(mc.Config) > 0 { + if err := json.Unmarshal(mc.Config, &body); err != nil { + return err + } + } + return p.PatchConfig(mc.Path, body) +} + +// RemoveModuleConfig removes a module configuration fragment from the live +// config. Caddy returns an error for a missing path; that is treated as +// already-removed and ignored. +func (p *APIProvider) RemoveModuleConfig(mc database.ModuleConfig) error { + exists, err := p.configExists("/config/" + trimConfigPrefix(mc.Path)) + if err != nil { + return err + } + if !exists { + return nil + } + return p.do("DELETE", "/config/"+trimConfigPrefix(mc.Path), nil, nil) +} + +// trimConfigPrefix normalises a stored path so it can be appended to /config/. +func trimConfigPrefix(path string) string { + for len(path) > 0 && path[0] == '/' { + path = path[1:] + } + const prefix = "config/" + if len(path) >= len(prefix) && path[:len(prefix)] == prefix { + path = path[len(prefix):] + } + return path +} diff --git a/backend/internal/caddy/modules.go b/backend/internal/caddy/modules.go new file mode 100644 index 0000000..d52c610 --- /dev/null +++ b/backend/internal/caddy/modules.go @@ -0,0 +1,95 @@ +package caddy + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "sort" + "strings" + "time" + + "github.com/Pacerino/CaddyProxyManager/internal/config" +) + +// Module describes a single Caddy module (plugin) reported by +// `caddy list-modules --json`. +type Module struct { + // ID is the full dotted module name, e.g. "http.handlers.reverse_proxy" + // or "dns.providers.cloudflare". + ID string `json:"id"` + // Namespace is the module's parent namespace, derived from ID. For + // "dns.providers.cloudflare" it is "dns.providers". + Namespace string `json:"namespace"` + // Name is the leaf segment of the ID, e.g. "cloudflare". + Name string `json:"name"` + // Standard reports whether the module ships with stock Caddy. Anything + // that is not "standard" is treated as a user-supplied plugin. + Standard bool `json:"standard"` + // Version and Package are informational, taken from the build info. + Version string `json:"version,omitempty"` + Package string `json:"package,omitempty"` +} + +// rawModule mirrors the JSON object emitted by `caddy list-modules --json`. +type rawModule struct { + ModuleName string `json:"module_name"` + ModuleType string `json:"module_type"` + Version string `json:"version"` + PackageURL string `json:"package_url"` +} + +// ListModules runs `caddy list-modules --json` against the configured binary +// and returns the parsed module list sorted by ID. It is the canonical way to +// discover which plugins a user-supplied Caddy build provides. +func ListModules() ([]Module, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, config.Configuration.Caddy.Binary, "list-modules", "--json") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("running %q list-modules: %w", config.Configuration.Caddy.Binary, err) + } + + return parseModules(out) +} + +// parseModules decodes the JSON output of `caddy list-modules --json`. +func parseModules(data []byte) ([]Module, error) { + var raw []rawModule + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing list-modules output: %w", err) + } + + modules := make([]Module, 0, len(raw)) + for _, rm := range raw { + id := strings.TrimSpace(rm.ModuleName) + if id == "" { + continue + } + namespace, name := splitModuleID(id) + modules = append(modules, Module{ + ID: id, + Namespace: namespace, + Name: name, + Standard: rm.ModuleType == "standard", + Version: rm.Version, + Package: rm.PackageURL, + }) + } + + sort.Slice(modules, func(i, j int) bool { return modules[i].ID < modules[j].ID }) + return modules, nil +} + +// splitModuleID splits a dotted module ID into its namespace and leaf name. +// For "dns.providers.cloudflare" it returns ("dns.providers", "cloudflare"). +// For a top-level id like "http" it returns ("", "http"). +func splitModuleID(id string) (namespace, name string) { + idx := strings.LastIndex(id, ".") + if idx < 0 { + return "", id + } + return id[:idx], id[idx+1:] +} diff --git a/backend/internal/caddy/schema/basicauth.go b/backend/internal/caddy/schema/basicauth.go new file mode 100644 index 0000000..3c410a1 --- /dev/null +++ b/backend/internal/caddy/schema/basicauth.go @@ -0,0 +1,61 @@ +package schema + +import ( + "encoding/base64" + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +func init() { + Register(&Schema{ + ModuleID: "http.handlers.authentication", + Title: "HTTP Basic Authentication", + Description: "Protect this host with HTTP basic auth. Enter a plaintext password; CPM stores only the bcrypt hash Caddy needs.", + Scopes: []Scope{ScopeHost}, + Fields: []Field{ + { + Key: "username", + Label: "Username", + Type: FieldString, + Required: true, + }, + { + Key: "password", + Label: "Password", + Description: "Plaintext password. It is bcrypt-hashed before being stored.", + Type: FieldSecret, + Required: true, + }, + }, + buildHandler: func(values map[string]any) (map[string]any, error) { + username, _ := values["username"].(string) + password, _ := values["password"].(string) + if username == "" || password == "" { + return nil, fmt.Errorf("username and password are required") + } + + // Caddy's http_basic expects the account password as a + // base64-encoded bcrypt hash. + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + encoded := base64.StdEncoding.EncodeToString(hash) + + return map[string]any{ + "handler": "authentication", + "providers": map[string]any{ + "http_basic": map[string]any{ + "accounts": []any{ + map[string]any{ + "username": username, + "password": encoded, + }, + }, + }, + }, + }, nil + }, + }) +} diff --git a/backend/internal/caddy/schema/cloudflare.go b/backend/internal/caddy/schema/cloudflare.go new file mode 100644 index 0000000..8f3b58e --- /dev/null +++ b/backend/internal/caddy/schema/cloudflare.go @@ -0,0 +1,39 @@ +package schema + +func init() { + Register(&Schema{ + ModuleID: "dns.providers.cloudflare", + Title: "Cloudflare DNS", + Description: "DNS provider used for ACME DNS-01 challenges via the Cloudflare API.", + Scopes: []Scope{ScopeGlobal}, + Path: "apps/tls/automation/policies", + Fields: []Field{ + { + Key: "api_token", + Label: "API Token", + Description: "Cloudflare API token with DNS edit permissions.", + Type: FieldSecret, + Required: true, + }, + }, + build: func(values map[string]any) (any, error) { + // Caddy's tls automation policy referencing the cloudflare DNS + // provider for the ACME issuer's DNS-01 challenge. + return map[string]any{ + "issuers": []any{ + map[string]any{ + "module": "acme", + "challenges": map[string]any{ + "dns": map[string]any{ + "provider": map[string]any{ + "name": "cloudflare", + "api_token": values["api_token"], + }, + }, + }, + }, + }, + }, nil + }, + }) +} diff --git a/backend/internal/caddy/schema/schema.go b/backend/internal/caddy/schema/schema.go new file mode 100644 index 0000000..0bdb712 --- /dev/null +++ b/backend/internal/caddy/schema/schema.go @@ -0,0 +1,196 @@ +// Package schema describes how known Caddy modules (plugins) are configured +// through CPM. Each registered Schema drives a typed form in the UI and is +// validated before being applied. Modules without a registered schema fall +// back to a raw JSON editor. +package schema + +import ( + "encoding/json" + "fmt" + "sort" +) + +// FieldType enumerates the input types a schema field can render as. +type FieldType string + +const ( + FieldString FieldType = "string" + FieldInt FieldType = "int" + FieldBool FieldType = "bool" + // FieldSecret is a string that the UI should mask. + FieldSecret FieldType = "secret" +) + +// Scope describes where a plugin is configured. +type Scope string + +const ( + // ScopeGlobal: configured once for the whole Caddy instance (e.g. a DNS + // provider used for TLS issuance). Managed on the Plugins page. + ScopeGlobal Scope = "global" + // ScopeHost: configured per host, rendered as a route handler attached to + // the host's route. Managed inside the host editor. + ScopeHost Scope = "host" +) + +// Field describes a single configurable property of a module. +type Field struct { + // Key is the JSON property name within the module's config object. + Key string `json:"key"` + // Label and Description are human-facing UI hints. + Label string `json:"label"` + Description string `json:"description,omitempty"` + Type FieldType `json:"type"` + Required bool `json:"required,omitempty"` + Default any `json:"default,omitempty"` + Placeholder string `json:"placeholder,omitempty"` +} + +// Schema is the typed configuration descriptor for one Caddy module. +type Schema struct { + // ModuleID is the dotted Caddy module name, e.g. "dns.providers.cloudflare". + ModuleID string `json:"moduleId"` + // Title and Description describe the plugin to the user. + Title string `json:"title"` + Description string `json:"description,omitempty"` + // Scopes lists where this plugin can be configured (global, host or both). + Scopes []Scope `json:"scopes"` + // Path is the default admin API config path the rendered config is + // patched into, relative to /config/. Only used for global scope. + Path string `json:"path,omitempty"` + // Fields are the typed inputs presented in the UI. + Fields []Field `json:"fields"` + // build converts validated form values into the global Caddy JSON + // fragment. When nil, the values map is emitted as-is. + build func(values map[string]any) (any, error) + // buildHandler converts validated form values into a Caddy HTTP route + // handler object, injected into a host's route. Required for host scope. + buildHandler func(values map[string]any) (map[string]any, error) +} + +// HasScope reports whether the schema supports the given scope. +func (s *Schema) HasScope(scope Scope) bool { + for _, sc := range s.Scopes { + if sc == scope { + return true + } + } + return false +} + +var registry = map[string]*Schema{} + +// Register adds a schema to the registry. It is intended to be called from +// package init functions of individual module schema files. +func Register(s *Schema) { + registry[s.ModuleID] = s +} + +// Get returns the schema for a module ID and whether one is registered. +func Get(moduleID string) (*Schema, bool) { + s, ok := registry[moduleID] + return s, ok +} + +// All returns every registered schema, sorted by module ID. +func All() []*Schema { + out := make([]*Schema, 0, len(registry)) + for _, s := range registry { + out = append(out, s) + } + sort.Slice(out, func(i, j int) bool { return out[i].ModuleID < out[j].ModuleID }) + return out +} + +// WithScope returns the registered schemas supporting the given scope, sorted +// by module ID. +func WithScope(scope Scope) []*Schema { + out := make([]*Schema, 0) + for _, s := range All() { + if s.HasScope(scope) { + out = append(out, s) + } + } + return out +} + +// Validate checks form values against the schema's field definitions, +// returning a map of field key -> error message for any problems. +func (s *Schema) Validate(values map[string]any) map[string]string { + errs := map[string]string{} + for _, f := range s.Fields { + v, present := values[f.Key] + if !present || v == nil || v == "" { + if f.Required { + errs[f.Key] = "is required" + } + continue + } + if msg := checkType(f, v); msg != "" { + errs[f.Key] = msg + } + } + return errs +} + +// Build validates and converts form values into the global Caddy JSON fragment. +func (s *Schema) Build(values map[string]any) (json.RawMessage, error) { + if errs := s.Validate(values); len(errs) > 0 { + return nil, &ValidationError{Fields: errs} + } + var fragment any = values + if s.build != nil { + built, err := s.build(values) + if err != nil { + return nil, err + } + fragment = built + } + return json.Marshal(fragment) +} + +// BuildHandler validates form values and renders the Caddy route handler +// object for a host-scoped plugin. +func (s *Schema) BuildHandler(values map[string]any) (map[string]any, error) { + if s.buildHandler == nil { + return nil, fmt.Errorf("module %q does not support host scope", s.ModuleID) + } + if errs := s.Validate(values); len(errs) > 0 { + return nil, &ValidationError{Fields: errs} + } + return s.buildHandler(values) +} + +// ValidationError carries per-field validation failures. +type ValidationError struct { + Fields map[string]string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("schema validation failed: %v", e.Fields) +} + +func checkType(f Field, v any) string { + switch f.Type { + case FieldString, FieldSecret: + if _, ok := v.(string); !ok { + return "must be a string" + } + case FieldInt: + // JSON numbers decode to float64. + switch n := v.(type) { + case float64: + if n != float64(int64(n)) { + return "must be an integer" + } + case int, int64: + default: + return "must be an integer" + } + case FieldBool: + if _, ok := v.(bool); !ok { + return "must be a boolean" + } + } + return "" +} diff --git a/backend/internal/database/models.go b/backend/internal/database/models.go index 0002926..abf0c4a 100644 --- a/backend/internal/database/models.go +++ b/backend/internal/database/models.go @@ -1,12 +1,31 @@ package database -import "gorm.io/gorm" +import ( + "database/sql/driver" + "encoding/json" + "errors" + + "gorm.io/gorm" +) type Host struct { gorm.Model Domains string `json:"domains" validate:"required,domains"` Matcher string `json:"matcher"` Upstreams []Upstream + Plugins []HostPlugin +} + +// HostPlugin is a per-host plugin configuration. Handler holds the rendered +// Caddy route handler JSON (secrets already hashed), which is injected into +// the host's route. ModuleID is the dotted Caddy module name. +type HostPlugin struct { + gorm.Model + HostID uint `json:"hostId" gorm:"index"` + ModuleID string `json:"moduleId" validate:"required"` + // Handler is the rendered route handler object applied to the route. + Handler JSON `json:"handler"` + Enabled bool `json:"enabled" gorm:"default:true"` } type Upstream struct { @@ -15,6 +34,65 @@ type Upstream struct { Backend string `json:"backend" validate:"required,hostname_port"` } +// JSON is a json.RawMessage that persists as TEXT in SQLite via the +// Valuer/Scanner interfaces. +type JSON json.RawMessage + +// Value implements driver.Valuer. +func (j JSON) Value() (driver.Value, error) { + if len(j) == 0 { + return "null", nil + } + return string(j), nil +} + +// Scan implements sql.Scanner. +func (j *JSON) Scan(value any) error { + if value == nil { + *j = JSON("null") + return nil + } + switch v := value.(type) { + case []byte: + *j = append((*j)[0:0], v...) + case string: + *j = append((*j)[0:0], v...) + default: + return errors.New("unsupported type for JSON column") + } + return nil +} + +// MarshalJSON makes JSON serialise as raw JSON rather than a byte array. +func (j JSON) MarshalJSON() ([]byte, error) { + if len(j) == 0 { + return []byte("null"), nil + } + return j, nil +} + +// UnmarshalJSON captures raw JSON as-is. +func (j *JSON) UnmarshalJSON(data []byte) error { + *j = append((*j)[0:0], data...) + return nil +} + +// ModuleConfig stores a configuration fragment for a single Caddy module +// (plugin). Config is applied to the live Caddy config at Path via the admin +// API. ModuleID is the dotted Caddy module name, e.g. "dns.providers.cloudflare". +type ModuleConfig struct { + gorm.Model + // ModuleID is the dotted Caddy module name this fragment configures. + ModuleID string `json:"moduleId" gorm:"uniqueIndex" validate:"required"` + // Path is the admin API config path the fragment is patched into, + // e.g. "apps/tls/automation/policies". Relative to /config/. + Path string `json:"path" validate:"required"` + // Config is the JSON fragment patched into Path. + Config JSON `json:"config" validate:"required"` + // Enabled controls whether the fragment is applied on reconcile. + Enabled bool `json:"enabled" gorm:"default:true"` +} + type User struct { gorm.Model Name string `validate:"required"` diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go index 40d7e96..04e5735 100644 --- a/backend/internal/database/sqlite.go +++ b/backend/internal/database/sqlite.go @@ -17,7 +17,7 @@ var dbInstance *gorm.DB func NewDB() { logger.Info("Creating new DB instance") dbInstance = SqliteDB() - dbInstance.AutoMigrate(&Host{}, &Upstream{}, &User{}) + dbInstance.AutoMigrate(&Host{}, &Upstream{}, &HostPlugin{}, &User{}, &ModuleConfig{}) seedAdmin() } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6277975..495ab27 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,15 +29,48 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.10.5", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^25.0.1", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^3.2.6" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -272,6 +305,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -320,6 +363,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2991,6 +3149,104 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3036,6 +3292,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3102,6 +3376,121 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -3114,6 +3503,31 @@ "node": ">= 6.0.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3126,6 +3540,26 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3191,6 +3625,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3225,6 +3669,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -3265,6 +3736,34 @@ "dev": true, "license": "MIT" }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3272,6 +3771,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3289,6 +3802,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3298,6 +3828,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3314,6 +3854,14 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3349,6 +3897,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3367,6 +3928,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -3446,6 +4014,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3638,6 +4226,43 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -3651,6 +4276,36 @@ "node": ">= 6" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3667,6 +4322,71 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3966,6 +4686,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3985,6 +4712,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4025,6 +4763,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4070,6 +4818,43 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4119,6 +4904,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -4128,6 +4929,16 @@ "node": ">=10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/radix-ui": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.5.0.tgz", @@ -4246,6 +5057,14 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4357,6 +5176,20 @@ } } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/rollup": { "version": "4.62.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", @@ -4402,6 +5235,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -4421,6 +5281,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sonner": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", @@ -4441,6 +5308,60 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", @@ -4472,6 +5393,20 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -4489,6 +5424,82 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4674,6 +5685,219 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index fd326b9..9ad0cfc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "lint": "tsc -b --noEmit" + "lint": "tsc -b --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@hookform/resolvers": "^3.9.1", @@ -31,12 +33,17 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.10.5", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^25.0.1", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^3.2.6" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d705e67..93d6d67 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { Layout } from "@/components/Layout"; import { LoginPage } from "@/pages/Login"; import { OIDCCallbackPage } from "@/pages/OIDCCallback"; import { HostsPage } from "@/pages/Hosts"; +import { PluginsPage } from "@/pages/Plugins"; import type { ReactNode } from "react"; function RequireAuth({ children }: { children: ReactNode }) { @@ -27,6 +28,7 @@ function App() { } > } /> + } /> } /> diff --git a/frontend/src/components/HostDialog.tsx b/frontend/src/components/HostDialog.tsx index 84a7f43..330ebe7 100644 --- a/frontend/src/components/HostDialog.tsx +++ b/frontend/src/components/HostDialog.tsx @@ -14,6 +14,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { HostPluginsSection } from "@/components/HostPluginsSection"; import type { Host } from "@/lib/types"; const schema = z.object({ @@ -183,6 +184,8 @@ export function HostDialog({ + + {host && } ); diff --git a/frontend/src/components/HostPluginsSection.tsx b/frontend/src/components/HostPluginsSection.tsx new file mode 100644 index 0000000..4d25878 --- /dev/null +++ b/frontend/src/components/HostPluginsSection.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + SchemaFields, + defaultFieldValues, + type FieldValues, +} from "@/components/SchemaFields"; +import { api, unwrap, type ApiEnvelope } from "@/lib/api"; +import type { + CaddyModulesResponse, + Host, + HostPlugin, + PluginSchema, +} from "@/lib/types"; + +interface HostPluginsSectionProps { + host: Host; +} + +// HostPluginsSection lets the user configure per-host plugins (e.g. basic +// auth) inside the host editor. It only offers plugins whose module is present +// in the running Caddy build and that declare host scope. +export function HostPluginsSection({ host }: HostPluginsSectionProps) { + const [schemas, setSchemas] = useState([]); + const [available, setAvailable] = useState>(new Set()); + const [configured, setConfigured] = useState(host.Plugins ?? []); + const [selected, setSelected] = useState(""); + const [values, setValues] = useState({}); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + try { + const [schemaRes, modRes] = await Promise.all([ + api.get>("/caddy/host-schemas"), + api.get>("/caddy/modules"), + ]); + setSchemas(unwrap(schemaRes.data) ?? []); + const mods = unwrap(modRes.data); + const ids = new Set( + (mods?.modules ?? []).map((m) => m.id) + ); + setAvailable(ids); + } catch { + // Non-fatal: section simply shows nothing configurable. + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + // Schemas usable for this host: host-scoped AND present in the build. + const usable = useMemo( + () => schemas.filter((s) => available.has(s.moduleId)), + [schemas, available] + ); + + const selectedSchema = usable.find((s) => s.moduleId === selected) ?? null; + + useEffect(() => { + if (selectedSchema) setValues(defaultFieldValues(selectedSchema.fields)); + }, [selectedSchema]); + + const refreshConfigured = useCallback(async () => { + try { + const res = await api.get>(`/hosts/${host.ID}`); + setConfigured(unwrap(res.data)?.Plugins ?? []); + } catch { + /* ignore */ + } + }, [host.ID]); + + const save = async () => { + if (!selectedSchema) return; + setSaving(true); + try { + await api.put(`/hosts/${host.ID}/plugins`, { + moduleId: selectedSchema.moduleId, + values, + }); + toast.success("Plugin configured"); + setSelected(""); + refreshConfigured(); + } catch (err) { + const msg = + (err as { response?: { data?: { error?: { message?: string } } } }) + .response?.data?.error?.message ?? "Failed to configure plugin"; + toast.error(msg); + } finally { + setSaving(false); + } + }; + + const remove = async (pluginID: number) => { + try { + await api.delete(`/hosts/${host.ID}/plugins/${pluginID}`); + toast.success("Plugin removed"); + refreshConfigured(); + } catch { + toast.error("Failed to remove plugin"); + } + }; + + if (usable.length === 0) { + return ( +
+

Plugins

+

+ No host-configurable plugins are available in this Caddy build. +

+
+ ); + } + + return ( +
+

Plugins

+ + {configured.length > 0 && ( +
+ {configured.map((p) => ( +
+ + {schemas.find((s) => s.moduleId === p.moduleId)?.title ?? + p.moduleId} + {!p.enabled && " (disabled)"} + + +
+ ))} +
+ )} + +
+ + + {selectedSchema && ( +
+ {selectedSchema.description && ( +

+ {selectedSchema.description} +

+ )} + + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 537b3c7..3353cd7 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,8 +1,14 @@ -import { Outlet, useNavigate } from "react-router-dom"; +import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { LogOut, Server } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; import { useAuth } from "@/lib/auth"; +const navItems = [ + { to: "/", label: "Hosts", end: true }, + { to: "/plugins", label: "Plugins", end: false }, +]; + export function Layout() { const { logout } = useAuth(); const navigate = useNavigate(); @@ -11,9 +17,30 @@ export function Layout() {
-
- - Caddy Proxy Manager +
+
+ + Caddy Proxy Manager +
+