diff --git a/cmd/silo/main.go b/cmd/silo/main.go index ee29348e..cc4232aa 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -327,9 +327,14 @@ func runCredentialBackfills(ctx context.Context, pool *pgxpool.Pool, cipher *sec if err != nil { slog.ErrorContext(ctx, "secret backfill: arr api keys", "component", "app", "error", err) } - if total := settingsN + columnsN + historyServersN + arrN; total > 0 { + pluginConfigsN, err := plugins.NewRuntimeConfigStore(pool, cipher).BackfillEncryptedConfigs(ctx) + if err != nil { + slog.ErrorContext(ctx, "secret backfill: plugin runtime configs", "component", "app", "error", err) + } + if total := settingsN + columnsN + historyServersN + arrN + pluginConfigsN; total > 0 { slog.InfoContext(ctx, "secret backfill: encrypted plaintext credentials at rest", "component", "app", - "settings", settingsN, "columns", columnsN, "history_session_servers", historyServersN, "arr_keys", arrN, "total", total) + "settings", settingsN, "columns", columnsN, "history_session_servers", historyServersN, + "arr_keys", arrN, "plugin_configs", pluginConfigsN, "total", total) } } @@ -721,6 +726,14 @@ func main() { bootstrapSensitiveConfigured["redis.url"] = true bootstrapSensitiveValues["redis.url"] = bc.RedisURL } + if rawTrustedProxies := strings.TrimSpace(os.Getenv(clientip.EnvTrustedProxies)); rawTrustedProxies != "" { + normalizedTrustedProxies, normalizeErr := clientip.NormalizeCIDRList(rawTrustedProxies) + if normalizeErr != nil { + log.Fatalf("invalid %s: %v", clientip.EnvTrustedProxies, normalizeErr) + } + bootstrapSensitiveConfigured[clientip.SettingTrustedProxies] = true + bootstrapSensitiveValues[clientip.SettingTrustedProxies] = normalizedTrustedProxies + } // Shared Redis client for components needing raw Redis beyond the event // bus (websocket handshake tickets, session listing). Nil on Redis-less @@ -736,6 +749,9 @@ func main() { // OnServerSettingUpdated closure, which only runs on admin requests after // startup completes. var ipResolver *clientip.Resolver + normalizedBootstrapRedisURL, bootstrapRedisURLErr := config.NormalizeRedisURL(bc.RedisURL) + redisBootstrapAvailable := (normalizedBootstrapRedisURL != "" && bootstrapRedisURLErr == nil) || + (strings.TrimSpace(cfg.Redis.SentinelMaster) != "" && len(cfg.Redis.SentinelAddresses) > 0) deps := api.Dependencies{ Config: cfg, @@ -743,6 +759,7 @@ func main() { OnConfigChange: configWatcher.OnChange, BootstrapSensitiveConfigured: bootstrapSensitiveConfigured, BootstrapSensitiveValues: bootstrapSensitiveValues, + RedisBootstrapAvailable: redisBootstrapAvailable, AppContext: appCtx, DB: pool, SecretCipher: dataCipher, @@ -948,7 +965,7 @@ func main() { pluginCacheDir := resolvePluginCacheDir() repositoryStore := plugins.NewRepositoryStore(deps.DB) installationStore := plugins.NewInstallationStore(deps.DB) - runtimeConfigStore := plugins.NewRuntimeConfigStore(deps.DB) + runtimeConfigStore := plugins.NewRuntimeConfigStore(deps.DB, deps.SecretCipher) catalogService := plugins.NewCatalogService(repositoryStore, plugins.CatalogServiceOptions{ SiloAPIVersion: plugins.DefaultSiloAPIVersion, }) diff --git a/docs/architecture/secret-encryption.md b/docs/architecture/secret-encryption.md index eb677b78..51764549 100644 --- a/docs/architecture/secret-encryption.md +++ b/docs/architecture/secret-encryption.md @@ -61,7 +61,8 @@ point of keeping the key out of the database. Recovery means re-entering the affected credentials: - Re-enter Sonarr/Radarr and Autoscan API keys, S3 keys, watch-sync connections, - history-import tokens, and subtitle provider credentials. + history-import tokens, subtitle provider credentials, and plugin runtime + configuration. - `auth.jwt_secret` becomes unreadable, so all existing sessions are invalid and users must log in again (a new secret is generated on next boot if the row is cleared — see below). @@ -79,7 +80,11 @@ remaining plaintext in place: 3. the two arr `api_key_ref` columns — these are **resolved-then-encrypted**: a legacy row that held a `server_settings` reference (e.g. `requests.radarr.api_key`) is collapsed to the real credential before being - encrypted. + encrypted, +4. whole `plugin_runtime_configs.config_value` objects. Plugin config is + intentionally opaque to the host storage layer: plugins may write undeclared + keys and manifest annotations may be unavailable or change over time, so + manifest-selected field encryption cannot fail closed. The backfill is safe to run repeatedly: already-encrypted values are skipped, and a per-row guard makes concurrent multi-node boots converge without @@ -92,7 +97,9 @@ whatever the primary encrypted. No manual steps are required. Downgrading to a binary that predates this change is **not** safe while secrets are encrypted, because the old binary has no read path: it would read `enc:v1:auth.jwt_secret` as a literal JWT secret (invalidating all sessions) and -read `enc:v1:`-prefixed integration keys as garbage credentials. +read `enc:v1:`-prefixed integration keys as garbage credentials. It would also +pass the reserved plugin-config envelope object to plugins instead of their +configuration. To downgrade safely you must first return the affected values to plaintext, for example: @@ -123,7 +130,10 @@ history-import admin/session tokens and temporary server-list credentials, subtitle provider `api_key`/`password`, the Jellyfin-compat session's bridged Silo access/refresh tokens (`jellycompat_sessions.streamapp_access_token` / `streamapp_refresh_token`), and -the ABS signing key. +the ABS signing key. Plugin runtime configuration is encrypted as one opaque +row-bound envelope rather than by manifest field. Consequently, runtime code +must use `RuntimeConfigStore`; database JSON-member queries and indexes are not +supported for `plugin_runtime_configs.config_value`. Deliberately **not** encrypted (tracked as follow-ups): @@ -132,11 +142,6 @@ Deliberately **not** encrypted (tracked as follow-ups): lookup. They need a deterministic **blind-index hash** column instead: `api_keys.api_key`, `webhook_sync_connections.webhook_secret`, `jellycompat_sessions.token`, and `watch_together_rooms.join_token`. -- **Plugin runtime config** — `plugin_runtime_configs.config_value` is opaque - plugin-defined JSONB whose secret fields are manifest-declared and whose runtime - lives in a separate repo; encrypting it correctly needs a coordinated, - manifest-aware design. - Excluded (not a gap): `plex_sync_connections.*` is a dead table (zero Go references); `oauth_completion.token_ciphertext` is already AES-GCM; `users.password_hash` and the `*_hash` columns are already hashed. diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index 979df400..0f6ac304 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "log/slog" + "maps" "math" "net/http" "net/url" @@ -65,6 +66,24 @@ type ServerSettingsStore interface { GetAll(ctx context.Context) (map[string]string, error) } +type serverSettingsAtomicUpdater interface { + UpdateAtomic( + ctx context.Context, + update func(current map[string]string) (map[string]string, error), + ) error +} + +func updateServerSettingsAtomically( + ctx context.Context, + store ServerSettingsStore, + update func(current map[string]string) (map[string]string, error), +) error { + if updater, ok := store.(serverSettingsAtomicUpdater); ok { + return updater.UpdateAtomic(ctx, update) + } + return errors.New("settings store does not support atomic updates") +} + type DiagnosticsEnablementStore interface { PutStream(ctx context.Context, bucket, key string, r io.Reader, contentType string) error DeleteObject(ctx context.Context, bucket, key string) error @@ -112,6 +131,7 @@ type AdminHandler struct { AccessGroups AccessGroupValidator BootstrapSensitiveConfigured map[string]bool BootstrapSensitiveValues map[string]string + RedisBootstrapAvailable bool OnUserSessionsRevoked func(ctx context.Context, userID int) OnServerSettingUpdated func(ctx context.Context, key, value string) RestartStatus *ServerRestartStatusTracker @@ -1254,6 +1274,26 @@ func (h *AdminHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, all) } +// HandleGetEffectiveSettings handles GET /admin/settings/effective. Unlike the +// legacy raw endpoint, missing rows are populated with the exact defaults used +// by runtime readers so an untouched form always describes active behavior. +func (h *AdminHandler) HandleGetEffectiveSettings(w http.ResponseWriter, r *http.Request) { + if h.SettingsRepo == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured") + return + } + all, err := h.SettingsRepo.GetAll(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load settings") + return + } + effective := h.effectiveAdminSettings(all) + for key := range sensitiveSettingKeys { + delete(effective, key) + } + writeJSON(w, http.StatusOK, effective) +} + type sensitiveStatusResponse struct { Configured []string `json:"configured"` ManagedByEnv []string `json:"managed_by_env,omitempty"` @@ -1295,7 +1335,7 @@ func (h *AdminHandler) HandleGetSensitiveStatus(w http.ResponseWriter, r *http.R managedByEnv := make([]string, 0, len(h.BootstrapSensitiveConfigured)) for key, configured := range h.BootstrapSensitiveConfigured { - if configured && sensitiveSettingKeys[key] { + if configured { managedByEnv = append(managedByEnv, key) } } @@ -2157,6 +2197,346 @@ type updateSettingRequest struct { Value string `json:"value"` } +type updateSettingsRequest struct { + Values map[string]string `json:"values"` +} + +type updateSettingsResponse struct { + Values map[string]string `json:"values"` + RestartRequired bool `json:"restart_required"` + RestartRequiredKeys []string `json:"restart_required_keys,omitempty"` +} + +func (h *AdminHandler) normalizeBatchSetting(ctx context.Context, key, value string) (string, string, error) { + if strings.HasPrefix(key, "ratelimit.") { + return "", "bad_request", fmt.Errorf("%s is managed by /admin/rate-limits/config", key) + } + normalized, err := config.NormalizeAdminSetting(key, value) + if err != nil { + return "", "bad_request", err + } + + switch key { + case markers.SettingMode, markers.SettingLazyPlayback: + normalized, err = markers.NormalizeSetting(key, normalized) + case clientip.SettingTrustedProxies: + normalized, err = clientip.NormalizeCIDRList(normalized) + if err != nil { + err = fmt.Errorf("clientip.trusted_proxies must be a comma-separated list of CIDRs: %w", err) + } + case "ai.asr_base_url": + if llm.IsChatOnlyGateway(normalized) { + err = errors.New("this endpoint cannot produce timestamped transcriptions; use a Whisper-compatible transcription endpoint") + } + case diagnostics.KeyUploadsEnabled: + if normalized == "true" { + if err = h.validateDiagnosticsUploadsEnabled(ctx); err != nil { + return "", "storage_unavailable", err + } + } + case diagnostics.KeyMaxBundleBytes, + diagnostics.KeyMaxUncompressedBytes, + diagnostics.KeyMaxReportsPerUserDay, + diagnostics.KeyRetentionDays, + diagnostics.KeyMaxBytesPerUser: + var numericValue int64 + numericValue, err = normalizeDiagnosticsNumericSettingValue(key, normalized) + if err == nil { + normalized = strconv.FormatInt(numericValue, 10) + } + case diagnostics.KeyConsentNoticeVersion: + var n int + n, err = strconv.Atoi(normalized) + if err == nil && n < 1 { + err = fmt.Errorf("%s must be an integer greater than 0", key) + } + if err == nil { + normalized = strconv.Itoa(n) + } + case notifications.SettingPushRelayURL, + notifications.SettingPushRelayDeploymentID, + notifications.SettingPushRelayAPIKey, + notifications.SettingPushRelayExpiresAt, + notifications.SettingPushRelayKeyPrefix, + notifications.SettingPushRelayReregister: + err = fmt.Errorf("%s is managed by the push relay registration flow", key) + case catalog.SearchSettingMeilisearchIndex: + if normalized == "" { + err = fmt.Errorf("%s is required", key) + } + case catalog.SearchSettingMeilisearchIndexTypes: + var itemTypes []string + itemTypes, err = catalog.NormalizeCatalogSearchIndexTypesValue(normalized) + if err == nil { + normalized = catalog.FormatCatalogSearchIndexTypesValue(itemTypes) + } + case catalog.SearchSettingMeilisearchEmbedder: + normalized, err = catalog.NormalizeCatalogSearchEmbedderName(normalized) + } + if err != nil { + return "", "bad_request", err + } + return normalized, "", nil +} + +func validateProspectiveAdminSettings(values map[string]string, redisBootstrapAvailable bool) error { + if err := config.ValidateAdminSettingsWithCapabilities(values, config.AdminSettingsCapabilities{ + RedisBootstrapAvailable: redisBootstrapAvailable, + }); err != nil { + return err + } + if _, err := catalog.CatalogSearchSettingsFromMap(values); err != nil { + return err + } + return validateProspectiveDiagnosticsSettings(values) +} + +var adminSettingDependencyGroups = [][]string{ + {"auth.access_token_expiry", "auth.refresh_token_expiry"}, + {"playback.watched_threshold", "playback.min_resume_threshold"}, + {"s3.public_endpoint", "s3.public_bucket"}, + {"s3.public_access_key", "s3.public_secret_key"}, + {"s3.private_endpoint", "s3.private_bucket"}, + {"s3.private_access_key", "s3.private_secret_key"}, + {"s3.public_url_auth", "s3.public_read_endpoint", "s3.public_token_secret"}, + {"email.enabled", "email.smtp_host", "email.from_address"}, + {"watchsync.trakt.client_id", "watchsync.trakt.client_secret"}, + {"watchsync.simkl.client_id", "watchsync.simkl.client_secret"}, + {"ratelimit.backend", "redis.url"}, + {"download.max_per_period", "download.period_duration"}, + {"matcher.enable_tv_series_root_queue", "matcher.enable_tv_series_group_queue"}, + {"ai.max_concurrent_jobs", "subtitle_ai.max_concurrent_jobs"}, + { + diagnostics.KeyMaxBundleBytes, + diagnostics.KeyMaxUncompressedBytes, + diagnostics.KeyMaxReportsPerUserDay, + diagnostics.KeyRetentionDays, + diagnostics.KeyMaxBytesPerUser, + }, +} + +// adminSettingsValidationSnapshot validates exactly the requested changes and +// the current values they depend on. The legacy single-key endpoint predates +// cross-field validation, so any relationship (or catalog value) can already +// be invalid in storage. Untouched legacy state must not poison an unrelated +// batch, while touching any member pulls the complete dependency group into the +// snapshot so a new or still-invalid relationship is rejected. +func adminSettingsValidationSnapshot( + prospective map[string]string, + changed map[string]string, +) map[string]string { + snapshot := config.EffectiveAdminSettings(nil) + effectiveProspective := config.EffectiveAdminSettings(prospective) + for key, value := range changed { + snapshot[key] = value + } + for _, group := range adminSettingDependencyGroups { + touched := false + for _, key := range group { + if _, ok := changed[key]; ok { + touched = true + break + } + } + if !touched { + continue + } + for _, key := range group { + snapshot[key] = effectiveProspective[key] + } + } + + // Operational S3 values are legacy fallbacks shared by the public and + // private configurations. When one changes, validate the canonical values + // that LoadFromDB will actually consume, including unchanged legacy peers. + legacyS3Changed := false + for key := range changed { + if strings.HasPrefix(key, "s3.operational_") { + legacyS3Changed = true + break + } + } + if legacyS3Changed { + //nolint:goconst // Keep the complete canonical validation set readable as a contract. + for _, key := range []string{ + "s3.public_endpoint", + "s3.public_read_endpoint", + "s3.public_region", + "s3.public_path_style", + "s3.public_bucket", + "s3.public_key_prefix", + "s3.public_access_key", + "s3.public_secret_key", + "s3.public_url_auth", + "s3.public_token_secret", + "s3.public_token_param", + "s3.public_token_ttl", + "s3.private_endpoint", + "s3.private_region", + "s3.private_path_style", + "s3.private_bucket", + "s3.private_key_prefix", + "s3.private_access_key", + "s3.private_secret_key", + } { + snapshot[key] = effectiveProspective[key] + } + } + return snapshot +} + +// activeAdminSettings overlays values owned by the process environment onto a +// stored snapshot. Updates never persist these values, but cross-field +// validation and effective-value comparisons must use the same configuration +// the runtime is actually consuming. +func (h *AdminHandler) activeAdminSettings(stored map[string]string) map[string]string { + active := make(map[string]string, len(stored)+len(h.BootstrapSensitiveValues)) + for key, value := range stored { + active[key] = value + } + for key, value := range h.BootstrapSensitiveValues { + if h.BootstrapSensitiveConfigured[key] && value != "" { + active[key] = value + } + } + return active +} + +func (h *AdminHandler) effectiveAdminSettings(stored map[string]string) map[string]string { + return config.EffectiveAdminSettings(h.activeAdminSettings(stored)) +} + +func shouldPersistAdminSetting(stored map[string]string, key, normalized string, effectiveChanged bool) bool { + current, exists := stored[key] + if exists { + return current != normalized + } + // Do not create a row merely because a client resubmitted an untouched + // runtime default. Non-default values still need a row, while clearing an + // already-absent override is a storage no-op. + return normalized != "" && effectiveChanged +} + +// HandleUpdateSettings handles PUT /admin/settings. Every requested value is +// normalized and validated with the prospective values it depends on before +// SetMany performs one transaction, so a multi-field save is all-or-nothing. +func (h *AdminHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { + if h.SettingsRepo == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured") + return + } + + var req updateSettingsRequest + decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if len(req.Values) == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "At least one setting is required") + return + } + if len(req.Values) > 250 { + writeError(w, http.StatusBadRequest, "bad_request", "A settings update may contain at most 250 values") + return + } + + keys := make([]string, 0, len(req.Values)) + for key := range req.Values { + if strings.TrimSpace(key) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + if h.BootstrapSensitiveConfigured[key] { + writeError(w, http.StatusBadRequest, "managed_by_environment", key+" is managed by an environment variable") + return + } + keys = append(keys, key) + } + sort.Strings(keys) + + normalized := make(map[string]string, len(req.Values)) + for _, key := range keys { + value, code, err := h.normalizeBatchSetting(r.Context(), key, req.Values[key]) + if err != nil { + writeError(w, http.StatusBadRequest, code, err.Error()) + return + } + normalized[key] = value + } + + var ( + after map[string]string + effectiveChanges map[string]bool + validationErr error + ) + err := updateServerSettingsAtomically(r.Context(), h.SettingsRepo, + func(stored map[string]string) (map[string]string, error) { + prospective := maps.Clone(stored) + for key, value := range normalized { + prospective[key] = value + } + activeProspective := h.activeAdminSettings(prospective) + validationSnapshot := adminSettingsValidationSnapshot(activeProspective, normalized) + if err := validateProspectiveAdminSettings(validationSnapshot, h.RedisBootstrapAvailable); err != nil { + validationErr = err + return nil, err + } + before := h.effectiveAdminSettings(stored) + after = h.effectiveAdminSettings(prospective) + writes := make(map[string]string, len(normalized)) + effectiveChanges = make(map[string]bool, len(normalized)) + for key, value := range normalized { + effectiveChanged := before[key] != after[key] + if shouldPersistAdminSetting(stored, key, value, effectiveChanged) { + writes[key] = value + } + if effectiveChanged { + effectiveChanges[key] = true + } + } + return writes, nil + }) + if validationErr != nil { + writeError(w, http.StatusBadRequest, "invalid_settings", validationErr.Error()) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update settings") + return + } + + responseValues := make(map[string]string, len(normalized)) + restartKeys := make([]string, 0, len(normalized)) + for _, key := range keys { + if !sensitiveSettingKeys[key] { + responseValues[key] = after[key] + } + if !effectiveChanges[key] { + continue + } + if h.EventBus != nil { + _ = h.EventBus.Publish(r.Context(), cache.ChannelAdmin, + cache.Event{Type: cache.EventSettingsChanged, Payload: key}) + } + if h.OnServerSettingUpdated != nil { + h.OnServerSettingUpdated(r.Context(), key, after[key]) + } + if config.RestartRequired(key) { + restartKeys = append(restartKeys, key) + } + } + if len(restartKeys) > 0 { + h.markServerRestartRequired("server_settings") + } + writeJSON(w, http.StatusOK, updateSettingsResponse{ + Values: responseValues, + RestartRequired: len(restartKeys) > 0, + RestartRequiredKeys: restartKeys, + }) +} + // HandleUpdateSetting handles PUT /admin/settings/{key}. func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Request) { if h.SettingsRepo == nil { @@ -2169,12 +2549,26 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") return } + if h.BootstrapSensitiveConfigured[key] { + writeError(w, http.StatusBadRequest, "managed_by_environment", key+" is managed by an environment variable") + return + } + if strings.HasPrefix(key, "ratelimit.") { + writeError(w, http.StatusBadRequest, "bad_request", key+" is managed by /admin/rate-limits/config") + return + } var req updateSettingRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } + if normalized, err := config.NormalizeAdminSetting(key, req.Value); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } else { + req.Value = normalized + } switch key { case markers.SettingMode, markers.SettingLazyPlayback: @@ -2401,19 +2795,56 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques req.Value = strconv.FormatBool(enabled) } - if err := h.SettingsRepo.Set(r.Context(), key, req.Value); err != nil { + var ( + after map[string]string + effectiveChanged bool + validationErr error + ) + err := updateServerSettingsAtomically(r.Context(), h.SettingsRepo, + func(stored map[string]string) (map[string]string, error) { + prospective := maps.Clone(stored) + prospective[key] = req.Value + // This legacy route can only change one key, so enforcing every + // cross-field invariant would make paired settings impossible to + // establish or clear one write at a time. Per-key validation above + // remains strict; Redis transport is the one durable prerequisite + // that may not be broken by a single-key write. + if key == "redis.url" { + if err := config.ValidateRedisRateLimitTransport( + h.activeAdminSettings(prospective), + h.RedisBootstrapAvailable, + ); err != nil { + validationErr = err + return nil, err + } + } + + before := h.effectiveAdminSettings(stored) + after = h.effectiveAdminSettings(prospective) + effectiveChanged = before[key] != after[key] + if shouldPersistAdminSetting(stored, key, req.Value, effectiveChanged) { + return map[string]string{key: req.Value}, nil + } + return nil, nil + }) + if validationErr != nil { + writeError(w, http.StatusBadRequest, "invalid_settings", validationErr.Error()) + return + } + if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update setting") return } - - if h.EventBus != nil { - _ = h.EventBus.Publish(r.Context(), cache.ChannelAdmin, - cache.Event{Type: cache.EventSettingsChanged, Payload: key}) + if effectiveChanged { + if h.EventBus != nil { + _ = h.EventBus.Publish(r.Context(), cache.ChannelAdmin, + cache.Event{Type: cache.EventSettingsChanged, Payload: key}) + } + if h.OnServerSettingUpdated != nil { + h.OnServerSettingUpdated(r.Context(), key, after[key]) + } } - if h.OnServerSettingUpdated != nil { - h.OnServerSettingUpdated(r.Context(), key, req.Value) - } - restartRequired := config.RestartRequired(key) + restartRequired := effectiveChanged && config.RestartRequired(key) if restartRequired { h.markServerRestartRequired("server_settings") } @@ -2421,7 +2852,7 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, RestartRequired: restartRequired}) return } - writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value, RestartRequired: restartRequired}) + writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: after[key], RestartRequired: restartRequired}) } func (h *AdminHandler) validateDiagnosticsUploadsEnabled(ctx context.Context) error { @@ -2445,14 +2876,9 @@ func (h *AdminHandler) validateDiagnosticsUploadsEnabled(ctx context.Context) er } func (h *AdminHandler) normalizeDiagnosticsNumericSetting(ctx context.Context, key, raw string) (string, error) { - const ( - mib = int64(1024 * 1024) - gib = int64(1024 * mib) - ) - - value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + value, err := normalizeDiagnosticsNumericSettingValue(key, raw) if err != nil { - return "", fmt.Errorf("%s must be an integer", key) + return "", err } settings := diagnostics.DefaultSettings() @@ -2466,9 +2892,6 @@ func (h *AdminHandler) normalizeDiagnosticsNumericSetting(ctx context.Context, k switch key { case diagnostics.KeyMaxBundleBytes: - if value < mib || value > 256*mib { - return "", fmt.Errorf("%s must be between 1 MiB (%d bytes) and 256 MiB (%d bytes)", key, mib, 256*mib) - } if value > settings.MaxUncompressedBytes { return "", fmt.Errorf("%s must not exceed %s (%d bytes)", key, diagnostics.KeyMaxUncompressedBytes, settings.MaxUncompressedBytes) } @@ -2478,21 +2901,12 @@ func (h *AdminHandler) normalizeDiagnosticsNumericSetting(ctx context.Context, k return "", fmt.Errorf("%s must not exceed %s (%d bytes)", key, diagnostics.KeyMaxBytesPerUser, settings.MaxBytesPerUser) } case diagnostics.KeyMaxUncompressedBytes: - if value < settings.MaxBundleBytes || value > gib { - return "", fmt.Errorf("%s must be between %s (%d bytes) and 1 GiB (%d bytes)", key, diagnostics.KeyMaxBundleBytes, settings.MaxBundleBytes, gib) - } - case diagnostics.KeyMaxReportsPerUserDay: - if value < 1 || value > 1000 { - return "", fmt.Errorf("%s must be between 1 and 1000", key) - } - case diagnostics.KeyRetentionDays: - if value < 1 || value > 365 { - return "", fmt.Errorf("%s must be between 1 and 365", key) + if value < settings.MaxBundleBytes { + return "", fmt.Errorf("%s must be at least %s (%d bytes)", key, diagnostics.KeyMaxBundleBytes, settings.MaxBundleBytes) } + case diagnostics.KeyMaxReportsPerUserDay, diagnostics.KeyRetentionDays: + // These settings have only independent bounds, which were checked above. case diagnostics.KeyMaxBytesPerUser: - if value < 10*mib || value > 10*gib { - return "", fmt.Errorf("%s must be between 10 MiB (%d bytes) and 10 GiB (%d bytes)", key, 10*mib, 10*gib) - } // The per-user cap must leave room for at least one max-size bundle, or // /diagnostics/status would advertise a bundle size InsertReceiving always // rejects as quota_exceeded. @@ -2505,3 +2919,99 @@ func (h *AdminHandler) normalizeDiagnosticsNumericSetting(ctx context.Context, k return strconv.FormatInt(value, 10), nil } + +const ( + diagnosticsMiB = int64(1024 * 1024) + diagnosticsGiB = 1024 * diagnosticsMiB +) + +func normalizeDiagnosticsNumericSettingValue(key, raw string) (int64, error) { + value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return 0, fmt.Errorf("%s must be an integer", key) + } + + switch key { + case diagnostics.KeyMaxBundleBytes: + if value < diagnosticsMiB || value > 256*diagnosticsMiB { + return 0, fmt.Errorf( + "%s must be between 1 MiB (%d bytes) and 256 MiB (%d bytes)", + key, + diagnosticsMiB, + 256*diagnosticsMiB, + ) + } + case diagnostics.KeyMaxUncompressedBytes: + if value < diagnosticsMiB || value > diagnosticsGiB { + return 0, fmt.Errorf( + "%s must be between 1 MiB (%d bytes) and 1 GiB (%d bytes)", + key, + diagnosticsMiB, + diagnosticsGiB, + ) + } + case diagnostics.KeyMaxReportsPerUserDay: + if value < 1 || value > 1000 { + return 0, fmt.Errorf("%s must be between 1 and 1000", key) + } + case diagnostics.KeyRetentionDays: + if value < 1 || value > 365 { + return 0, fmt.Errorf("%s must be between 1 and 365", key) + } + case diagnostics.KeyMaxBytesPerUser: + if value < 10*diagnosticsMiB || value > 10*diagnosticsGiB { + return 0, fmt.Errorf( + "%s must be between 10 MiB (%d bytes) and 10 GiB (%d bytes)", + key, + 10*diagnosticsMiB, + 10*diagnosticsGiB, + ) + } + default: + return 0, fmt.Errorf("unsupported diagnostics numeric setting %s", key) + } + return value, nil +} + +func validateProspectiveDiagnosticsSettings(values map[string]string) error { + settings := diagnostics.DefaultSettings() + targets := []struct { + key string + assign func(int64) + }{ + {diagnostics.KeyMaxBundleBytes, func(value int64) { settings.MaxBundleBytes = value }}, + {diagnostics.KeyMaxUncompressedBytes, func(value int64) { settings.MaxUncompressedBytes = value }}, + {diagnostics.KeyMaxReportsPerUserDay, func(value int64) { settings.MaxReportsPerUserDay = int(value) }}, + {diagnostics.KeyRetentionDays, func(value int64) { settings.RetentionDays = int(value) }}, + {diagnostics.KeyMaxBytesPerUser, func(value int64) { settings.MaxBytesPerUser = value }}, + } + for _, target := range targets { + raw := values[target.key] + if raw == "" { + continue + } + value, err := normalizeDiagnosticsNumericSettingValue(target.key, raw) + if err != nil { + return err + } + target.assign(value) + } + + if settings.MaxBundleBytes > settings.MaxUncompressedBytes { + return fmt.Errorf( + "%s must not exceed %s (%d bytes)", + diagnostics.KeyMaxBundleBytes, + diagnostics.KeyMaxUncompressedBytes, + settings.MaxUncompressedBytes, + ) + } + if settings.MaxBundleBytes > settings.MaxBytesPerUser { + return fmt.Errorf( + "%s must not exceed %s (%d bytes)", + diagnostics.KeyMaxBundleBytes, + diagnostics.KeyMaxBytesPerUser, + settings.MaxBytesPerUser, + ) + } + return nil +} diff --git a/internal/api/handlers/admin_apple_push.go b/internal/api/handlers/admin_apple_push.go index 241175a5..9c610d01 100644 --- a/internal/api/handlers/admin_apple_push.go +++ b/internal/api/handlers/admin_apple_push.go @@ -180,6 +180,26 @@ func (h *AdminApplePushHandler) HandleRegisterRelay(w http.ResponseWriter, r *ht }) } +// HandleClearRelay handles DELETE /admin/notifications/push/relay. Clearing +// the local capability is deliberately explicit: it lets an administrator +// change relay origins or recover from a revoked deployment without exposing +// credential fields through the generic settings endpoint. +func (h *AdminApplePushHandler) HandleClearRelay(w http.ResponseWriter, r *http.Request) { + if h == nil || h.settings == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Settings store is not available") + return + } + settings := notifications.NewSettings(h.settings) + if h.system != nil && h.system.Settings != nil { + settings = h.system.Settings + } + if err := settings.UpdatePushRelayCredential(r.Context(), notifications.PushRelayCredential{}); err != nil { + writeError(w, http.StatusInternalServerError, "settings_error", "Failed to clear push relay credential") + return + } + w.WriteHeader(http.StatusNoContent) +} + func mapRelayRegistrationError(err error) (int, string, string) { var relayErr notifications.RelayCredentialError if !errors.As(err, &relayErr) { diff --git a/internal/api/handlers/admin_apple_push_clear_test.go b/internal/api/handlers/admin_apple_push_clear_test.go new file mode 100644 index 00000000..94329d8f --- /dev/null +++ b/internal/api/handlers/admin_apple_push_clear_test.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Silo-Server/silo-server/internal/notifications" +) + +func TestAdminApplePushHandlerClearsRelayCredentialAtomically(t *testing.T) { + fixture := "clawrouter-e2e-secret" + settings := &fakeServerSettingsStore{values: map[string]string{ + notifications.SettingPushRelayURL: "https://push.siloserver.org", + notifications.SettingPushRelayDeploymentID: "deployment-existing", + notifications.SettingPushRelayAPIKey: fixture, + notifications.SettingPushRelayKeyPrefix: "cap_v1_existing", + notifications.SettingPushRelayExpiresAt: "2026-08-01T00:00:00Z", + notifications.SettingPushRelayReregister: "true", + }} + h := NewAdminApplePushHandler( + ¬ifications.System{Settings: notifications.NewSettings(settings)}, + settings, + ) + rec := httptest.NewRecorder() + + h.HandleClearRelay( + rec, + httptest.NewRequest(http.MethodDelete, "/admin/notifications/push/relay", nil), + ) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d (%s), want 204", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 1 { + t.Fatalf("SetMany calls = %d, want 1", settings.setManyCalls) + } + for _, key := range []string{ + notifications.SettingPushRelayURL, + notifications.SettingPushRelayDeploymentID, + notifications.SettingPushRelayAPIKey, + notifications.SettingPushRelayKeyPrefix, + notifications.SettingPushRelayExpiresAt, + } { + if settings.values[key] != "" { + t.Fatalf("%s = %q, want empty", key, settings.values[key]) + } + } + if settings.values[notifications.SettingPushRelayReregister] != "false" { + t.Fatalf( + "reregistration marker = %q, want false", + settings.values[notifications.SettingPushRelayReregister], + ) + } +} diff --git a/internal/api/handlers/admin_settings_atomic_test.go b/internal/api/handlers/admin_settings_atomic_test.go new file mode 100644 index 00000000..81eb9458 --- /dev/null +++ b/internal/api/handlers/admin_settings_atomic_test.go @@ -0,0 +1,297 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/config" +) + +type serializedSettingsStore struct { + mu sync.Mutex + + values map[string]string + + atomicCalls int + active int + maxActive int + directSets int + directBatch int +} + +type nonAtomicSettingsStore struct { + values map[string]string +} + +func (s *nonAtomicSettingsStore) Get(_ context.Context, key string) (string, error) { + return s.values[key], nil +} + +func (s *nonAtomicSettingsStore) GetAll(context.Context) (map[string]string, error) { + return cloneSettings(s.values), nil +} + +func (s *nonAtomicSettingsStore) Set(_ context.Context, key, value string) error { + s.values[key] = value + return nil +} + +func newSerializedSettingsStore(values map[string]string) *serializedSettingsStore { + return &serializedSettingsStore{values: values} +} + +func (s *serializedSettingsStore) Get(_ context.Context, key string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.values[key], nil +} + +func (s *serializedSettingsStore) GetAll(context.Context) (map[string]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return cloneSettings(s.values), nil +} + +func (s *serializedSettingsStore) Set(_ context.Context, key, value string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.directSets++ + s.values[key] = value + return nil +} + +func (s *serializedSettingsStore) SetMany(_ context.Context, values map[string]string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.directBatch++ + for key, value := range values { + s.values[key] = value + } + return nil +} + +func (s *serializedSettingsStore) UpdateAtomic( + _ context.Context, + update func(current map[string]string) (map[string]string, error), +) error { + s.mu.Lock() + defer s.mu.Unlock() + s.atomicCalls++ + s.active++ + if s.active > s.maxActive { + s.maxActive = s.active + } + defer func() { s.active-- }() + + // Widen the race window: a handler that reads outside this capability would + // allow both prospective snapshots to validate against the same state. + time.Sleep(10 * time.Millisecond) + writes, err := update(cloneSettings(s.values)) + if err != nil { + return err + } + for key, value := range writes { + s.values[key] = value + } + return nil +} + +func cloneSettings(values map[string]string) map[string]string { + cloned := make(map[string]string, len(values)) + for key, value := range values { + cloned[key] = value + } + return cloned +} + +func TestAdminSettingsAtomicUpdateSerializesCrossFieldValidation(t *testing.T) { + store := newSerializedSettingsStore(map[string]string{ + "auth.access_token_expiry": "8h", + "auth.refresh_token_expiry": "30d", + }) + handler := &AdminHandler{SettingsRepo: store} + start := make(chan struct{}) + responses := make(chan *httptest.ResponseRecorder, 2) + + run := func(body string) { + <-start + req := httptest.NewRequest(http.MethodPut, "/admin/settings", strings.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleUpdateSettings(rec, req) + responses <- rec + } + go run(`{"values":{"auth.access_token_expiry":"48h"}}`) + go run(`{"values":{"auth.refresh_token_expiry":"24h"}}`) + close(start) + + first := <-responses + second := <-responses + okCount := 0 + badRequestCount := 0 + for _, response := range []*httptest.ResponseRecorder{first, second} { + switch response.Code { + case http.StatusOK: + okCount++ + case http.StatusBadRequest: + badRequestCount++ + default: + t.Fatalf("unexpected status = %d body=%s", response.Code, response.Body.String()) + } + } + if okCount != 1 || badRequestCount != 1 { + t.Fatalf("statuses = [%d, %d], want one 200 and one 400", first.Code, second.Code) + } + + current, err := store.GetAll(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := validateProspectiveAdminSettings(current, false); err != nil { + t.Fatalf("serialized final settings are invalid: %v; values=%#v", err, current) + } + if store.atomicCalls != 2 || store.maxActive != 1 { + t.Fatalf("atomic calls=%d max active=%d, want 2 and 1", store.atomicCalls, store.maxActive) + } +} + +func TestAdminLegacySingleUpdateUsesAtomicSettingsBoundary(t *testing.T) { + store := newSerializedSettingsStore(map[string]string{}) + handler := &AdminHandler{SettingsRepo: store} + start := make(chan struct{}) + responses := make(chan *httptest.ResponseRecorder, 2) + + go func() { + <-start + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"branding.server_name":"Casa"}}`), + ) + rec := httptest.NewRecorder() + handler.HandleUpdateSettings(rec, req) + responses <- rec + }() + go func() { + <-start + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings/server.log_level", + strings.NewReader(`{"value":"debug"}`), + ) + req = withChiParam(req, "key", "server.log_level") + rec := httptest.NewRecorder() + handler.HandleUpdateSetting(rec, req) + responses <- rec + }() + close(start) + + for range 2 { + response := <-responses + if response.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) + } + } + if store.atomicCalls != 2 || store.maxActive != 1 { + t.Fatalf("atomic calls=%d max active=%d, want 2 and 1", store.atomicCalls, store.maxActive) + } + if store.directSets != 0 || store.directBatch != 0 { + t.Fatalf("direct writes: Set=%d SetMany=%d, want zero", store.directSets, store.directBatch) + } +} + +func TestRateLimitAndAdminRedisUpdatesShareAtomicBoundary(t *testing.T) { + store := newSerializedSettingsStore(map[string]string{ + "ratelimit.backend": "memory", + "redis.url": "redis://cache.example.invalid:6379", + }) + adminHandler := &AdminHandler{SettingsRepo: store} + rateLimitHandler := NewRateLimitHandler(store, nil, nil, NewServerRestartStatusTracker()) + start := make(chan struct{}) + responses := make(chan *httptest.ResponseRecorder, 2) + + go func() { + <-start + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"redis.url":""}}`), + ) + rec := httptest.NewRecorder() + adminHandler.HandleUpdateSettings(rec, req) + responses <- rec + }() + go func() { + <-start + req := httptest.NewRequest( + http.MethodPut, + "/admin/rate-limits/config", + strings.NewReader(`{"backend":"redis"}`), + ) + rec := httptest.NewRecorder() + rateLimitHandler.HandleUpdateConfig(rec, req) + responses <- rec + }() + close(start) + + first := <-responses + second := <-responses + okCount := 0 + badRequestCount := 0 + for _, response := range []*httptest.ResponseRecorder{first, second} { + switch response.Code { + case http.StatusOK: + okCount++ + case http.StatusBadRequest: + badRequestCount++ + default: + t.Fatalf("unexpected status = %d body=%s", response.Code, response.Body.String()) + } + } + if okCount != 1 || badRequestCount != 1 { + t.Fatalf("statuses = [%d, %d], want one 200 and one 400", first.Code, second.Code) + } + + current, err := store.GetAll(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := config.ValidateRedisRateLimitTransport(current, false); err != nil { + t.Fatalf("serialized final Redis transport is invalid: %v; values=%#v", err, current) + } +} + +func TestAdminSettingsWritesFailClosedWithoutAtomicCapability(t *testing.T) { + store := &nonAtomicSettingsStore{values: map[string]string{}} + + adminHandler := &AdminHandler{SettingsRepo: store} + adminReq := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"branding.server_name":"Casa"}}`), + ) + adminRec := httptest.NewRecorder() + adminHandler.HandleUpdateSettings(adminRec, adminReq) + if adminRec.Code != http.StatusInternalServerError { + t.Fatalf("Admin status = %d, want 500; body=%s", adminRec.Code, adminRec.Body.String()) + } + + rateLimitHandler := NewRateLimitHandler(store, nil, nil, NewServerRestartStatusTracker()) + rateReq := httptest.NewRequest( + http.MethodPut, + "/admin/rate-limits/config", + strings.NewReader(`{"enabled":false}`), + ) + rateRec := httptest.NewRecorder() + rateLimitHandler.HandleUpdateConfig(rateRec, rateReq) + if rateRec.Code != http.StatusInternalServerError { + t.Fatalf("rate-limit status = %d, want 500; body=%s", rateRec.Code, rateRec.Body.String()) + } + if len(store.values) != 0 { + t.Fatalf("non-atomic store was mutated: %#v", store.values) + } +} diff --git a/internal/api/handlers/admin_settings_checks.go b/internal/api/handlers/admin_settings_checks.go index 4989abc8..8bdb57e3 100644 --- a/internal/api/handlers/admin_settings_checks.go +++ b/internal/api/handlers/admin_settings_checks.go @@ -1,8 +1,10 @@ package handlers import ( + "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -14,6 +16,7 @@ import ( "github.com/Silo-Server/silo-server/internal/cache" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/mdblist" "github.com/Silo-Server/silo-server/internal/recommendations/embeddings" "github.com/Silo-Server/silo-server/internal/s3client" ) @@ -30,6 +33,9 @@ type connectionCheckResponse struct { type s3SettingsCheckClient interface { HeadBucket(ctx context.Context, bucket string) error + PutObject(ctx context.Context, bucket, key string, data []byte) error + GetObject(ctx context.Context, bucket, key string) ([]byte, error) + DeleteObject(ctx context.Context, bucket, key string) error } type redisSettingsCheckClient interface { @@ -41,6 +47,10 @@ type embeddingsSettingsCheckClient interface { Embed(ctx context.Context, texts []string) ([][]float32, error) } +type mdblistSettingsCheckClient interface { + Check(ctx context.Context) error +} + type redisSettingsCheckAdapter struct { client *redis.Client } @@ -74,6 +84,10 @@ var newAdminEmbeddingsSettingsCheckClient = func( return embeddings.NewClient(cfg) } +var newAdminMDBListSettingsCheckClient = func(apiKey string) mdblistSettingsCheckClient { + return mdblist.NewClient(apiKey, nil) +} + func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *http.Request) { if h.SettingsRepo == nil { writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured") @@ -119,6 +133,8 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h response = checkRecommendationsEmbeddingConnection(r.Context(), cfg) case "meilisearch": response = checkMeilisearchConnection(r.Context(), effectiveSettings) + case "mdblist": + response = checkMDBListConnection(r.Context(), cfg) default: writeError(w, http.StatusBadRequest, "bad_request", "Unsupported connection check kind") return @@ -127,6 +143,18 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h writeJSON(w, http.StatusOK, response) } +func checkMDBListConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse { + if strings.TrimSpace(cfg.MDBListAPIKey) == "" { + return connectionCheckResponse{Success: false, Message: "MDBList API key is required."} + } + checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + if err := newAdminMDBListSettingsCheckClient(cfg.MDBListAPIKey).Check(checkCtx); err != nil { + return connectionCheckResponse{Success: false, Message: fmt.Sprintf("MDBList connection check failed: %v", err)} + } + return connectionCheckResponse{Success: true, Message: "MDBList API key verified."} +} + func checkMeilisearchConnection(ctx context.Context, settings map[string]string) connectionCheckResponse { searchSettings, err := catalog.CatalogSearchSettingsFromMap(settings) if err != nil { @@ -230,10 +258,13 @@ func checkS3PublicConnection(ctx context.Context, cfg *config.Config) connection Message: fmt.Sprintf("S3 connection check failed: %v", err), } } + if err := checkS3ObjectPermissions(checkCtx, client, cfg.S3.Public.Bucket); err != nil { + return connectionCheckResponse{Success: false, Message: fmt.Sprintf("S3 object permission check failed: %v", err)} + } return connectionCheckResponse{ Success: true, - Message: "S3 connection successful.", + Message: "S3 connection and object read/write/delete permissions verified.", } } @@ -263,13 +294,58 @@ func checkS3PrivateConnection(ctx context.Context, cfg *config.Config) connectio Message: fmt.Sprintf("S3 connection check failed: %v", err), } } + if err := checkS3ObjectPermissions(checkCtx, client, cfg.S3.Private.Bucket); err != nil { + return connectionCheckResponse{Success: false, Message: fmt.Sprintf("S3 object permission check failed: %v", err)} + } return connectionCheckResponse{ Success: true, - Message: "S3 connection successful.", + Message: "S3 connection and object read/write/delete permissions verified.", } } +func checkS3ObjectPermissions( + ctx context.Context, + client s3SettingsCheckClient, + bucket string, +) (resultErr error) { + key := fmt.Sprintf(".silo-admin-connection-check/%d", time.Now().UnixNano()) + payload := []byte("silo-storage-check") + deleted := false + defer func() { + if deleted { + return + } + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + if err := client.DeleteObject(cleanupCtx, bucket, key); err != nil { + cleanupErr := fmt.Errorf("cleanup probe object: %w", err) + if resultErr == nil { + resultErr = cleanupErr + } else { + resultErr = errors.Join(resultErr, cleanupErr) + } + } + }() + + if err := client.PutObject(ctx, bucket, key, payload); err != nil { + return fmt.Errorf("write probe object: %w", err) + } + + read, err := client.GetObject(ctx, bucket, key) + if err != nil { + return fmt.Errorf("read probe object: %w", err) + } + if !bytes.Equal(read, payload) { + return fmt.Errorf("read probe object returned unexpected content") + } + if err := client.DeleteObject(ctx, bucket, key); err != nil { + return fmt.Errorf("delete probe object: %w", err) + } + deleted = true + return nil +} + func checkRedisConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse { if strings.TrimSpace(cfg.Redis.URL) == "" { return connectionCheckResponse{Success: false, Message: "Redis URL is required."} diff --git a/internal/api/handlers/admin_settings_checks_test.go b/internal/api/handlers/admin_settings_checks_test.go index 92313576..92df0bd4 100644 --- a/internal/api/handlers/admin_settings_checks_test.go +++ b/internal/api/handlers/admin_settings_checks_test.go @@ -4,8 +4,11 @@ import ( "bytes" "context" "encoding/json" + "errors" + "fmt" "net/http" "net/http/httptest" + "strconv" "strings" "testing" @@ -13,11 +16,15 @@ import ( "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/diagnostics" "github.com/Silo-Server/silo-server/internal/s3client" ) type fakeServerSettingsStore struct { - values map[string]string + values map[string]string + setCalls int + setManyCalls int + atomicCalls int } func (f *fakeServerSettingsStore) Get(_ context.Context, key string) (string, error) { @@ -25,6 +32,7 @@ func (f *fakeServerSettingsStore) Get(_ context.Context, key string) (string, er } func (f *fakeServerSettingsStore) Set(_ context.Context, key, value string) error { + f.setCalls++ if f.values == nil { f.values = map[string]string{} } @@ -33,6 +41,7 @@ func (f *fakeServerSettingsStore) Set(_ context.Context, key, value string) erro } func (f *fakeServerSettingsStore) SetMany(_ context.Context, values map[string]string) error { + f.setManyCalls++ if f.values == nil { f.values = map[string]string{} } @@ -42,6 +51,679 @@ func (f *fakeServerSettingsStore) SetMany(_ context.Context, values map[string]s return nil } +func (f *fakeServerSettingsStore) UpdateAtomic( + ctx context.Context, + update func(current map[string]string) (map[string]string, error), +) error { + f.atomicCalls++ + current, err := f.GetAll(ctx) + if err != nil { + return err + } + writes, err := update(current) + if err != nil || len(writes) == 0 { + return err + } + return f.SetMany(ctx, writes) +} + +func TestAdminGetEffectiveSettingsReturnsRuntimeDefaultsAndRedactsSecrets(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{ + "server.log_level": "debug", + "tmdb.api_key": "never-return-this", + }} + handler := &AdminHandler{SettingsRepo: settings} + rec := httptest.NewRecorder() + + handler.HandleGetEffectiveSettings(rec, httptest.NewRequest(http.MethodGet, "/admin/settings/effective", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var values map[string]string + if err := json.NewDecoder(rec.Body).Decode(&values); err != nil { + t.Fatalf("decode response: %v", err) + } + if values["server.log_level"] != "debug" { + t.Fatalf("server.log_level = %q, want debug", values["server.log_level"]) + } + if values["database.max_connections"] != "20" { + t.Fatalf("database.max_connections = %q, want effective default 20", values["database.max_connections"]) + } + if values["playback.transcode_enabled"] != "true" { + t.Fatalf("playback.transcode_enabled = %q, want effective default true", values["playback.transcode_enabled"]) + } + if _, leaked := values["tmdb.api_key"]; leaked { + t.Fatal("effective settings response leaked tmdb.api_key") + } +} + +func TestAdminGetEffectiveSettingsUsesEnvironmentManagedRuntimeValue(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{ + "clientip.trusted_proxies": "10.0.0.0/8", + }} + handler := &AdminHandler{ + SettingsRepo: settings, + BootstrapSensitiveConfigured: map[string]bool{ + "clientip.trusted_proxies": true, + "redis.url": true, + }, + BootstrapSensitiveValues: map[string]string{ + "clientip.trusted_proxies": "192.0.2.0/24, 2001:db8::/32", + "redis.url": "redis://private.example.invalid:6379", + }, + } + rec := httptest.NewRecorder() + + handler.HandleGetEffectiveSettings(rec, httptest.NewRequest(http.MethodGet, "/admin/settings/effective", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var values map[string]string + if err := json.NewDecoder(rec.Body).Decode(&values); err != nil { + t.Fatalf("decode response: %v", err) + } + if got := values["clientip.trusted_proxies"]; got != "192.0.2.0/24, 2001:db8::/32" { + t.Fatalf("clientip.trusted_proxies = %q, want active environment value", got) + } + if _, leaked := values["redis.url"]; leaked { + t.Fatal("effective settings response leaked environment-managed redis.url") + } +} + +func TestAdminSettingsValidationIncludesEnvironmentManagedValues(t *testing.T) { + newHandler := func() (*AdminHandler, *fakeServerSettingsStore) { + settings := &fakeServerSettingsStore{values: map[string]string{ + "watchsync.trakt.client_id": "configured-client-id", + }} + return &AdminHandler{ + SettingsRepo: settings, + BootstrapSensitiveConfigured: map[string]bool{ + "watchsync.trakt.client_secret": true, + }, + BootstrapSensitiveValues: map[string]string{ + "watchsync.trakt.client_secret": "clawrouter-e2e-secret", + }, + }, settings + } + + t.Run("batch update", func(t *testing.T) { + handler, settings := newHandler() + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"branding.server_name":"Casa"}}`), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["branding.server_name"] != "Casa" { + t.Fatalf("branding.server_name = %q, want Casa", settings.values["branding.server_name"]) + } + }) + + t.Run("single update", func(t *testing.T) { + handler, settings := newHandler() + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings/branding.server_name", + strings.NewReader(`{"value":"Casa"}`), + ) + req = withChiParam(req, "key", "branding.server_name") + rec := httptest.NewRecorder() + + handler.HandleUpdateSetting(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["branding.server_name"] != "Casa" { + t.Fatalf("branding.server_name = %q, want Casa", settings.values["branding.server_name"]) + } + }) +} + +func TestAdminSensitiveStatusReportsNonSecretEnvironmentManagedSettings(t *testing.T) { + handler := &AdminHandler{ + SettingsRepo: &fakeServerSettingsStore{values: map[string]string{}}, + BootstrapSensitiveConfigured: map[string]bool{ + "clientip.trusted_proxies": true, + }, + } + rec := httptest.NewRecorder() + + handler.HandleGetSensitiveStatus(rec, httptest.NewRequest(http.MethodGet, "/admin/settings/sensitive-status", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var response sensitiveStatusResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(response.ManagedByEnv) != 1 || response.ManagedByEnv[0] != "clientip.trusted_proxies" { + t.Fatalf("managed_by_env = %#v", response.ManagedByEnv) + } +} + +func TestAdminUpdateSettingsRejectsEnvironmentManagedSetting(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{}} + handler := &AdminHandler{ + SettingsRepo: settings, + BootstrapSensitiveConfigured: map[string]bool{ + "clientip.trusted_proxies": true, + }, + } + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"clientip.trusted_proxies":"10.0.0.0/8"}}`), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 0 { + t.Fatalf("SetMany calls = %d, want 0", settings.setManyCalls) + } +} + +func TestAdminUpdateSettingsCommitsOneValidatedBatch(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{}} + restartStatus := NewServerRestartStatusTracker() + handler := &AdminHandler{SettingsRepo: settings, RestartStatus: restartStatus} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"database.max_connections":" 40 ","branding.server_name":"Casa"}}`), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 1 { + t.Fatalf("SetMany calls = %d, want 1", settings.setManyCalls) + } + if settings.values["database.max_connections"] != "40" || + settings.values["branding.server_name"] != "Casa" { + t.Fatalf("stored values = %#v", settings.values) + } + var response updateSettingsResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if !response.RestartRequired || + len(response.RestartRequiredKeys) != 1 || + response.RestartRequiredKeys[0] != "database.max_connections" { + t.Fatalf("response = %#v", response) + } +} + +func TestAdminUpdateSettingsRejectsWholeBatchBeforeWrite(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{"branding.server_name": "Silo"}} + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"branding.server_name":"Casa","database.max_connections":"0"}}`), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 0 { + t.Fatalf("SetMany calls = %d, want 0", settings.setManyCalls) + } + if settings.values["branding.server_name"] != "Silo" { + t.Fatalf("valid sibling value was partially persisted: %#v", settings.values) + } +} + +func TestAdminUpdateSettingsValidatesProspectiveDiagnosticsLimits(t *testing.T) { + const ( + mib = 1024 * 1024 + ) + + t.Run("rejects invalid final relationship", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{ + diagnostics.KeyMaxBundleBytes: strconv.Itoa(10 * mib), + diagnostics.KeyMaxUncompressedBytes: strconv.Itoa(64 * mib), + diagnostics.KeyMaxBytesPerUser: strconv.Itoa(200 * mib), + }} + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(fmt.Sprintf( + `{"values":{"%s":"%d","%s":"%d"}}`, + diagnostics.KeyMaxBundleBytes, + 50*mib, + diagnostics.KeyMaxUncompressedBytes, + 20*mib, + )), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 0 { + t.Fatalf("invalid diagnostics batch wrote settings: SetMany=%d", settings.setManyCalls) + } + }) + + t.Run("accepts valid paired repair", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{ + diagnostics.KeyMaxBundleBytes: strconv.Itoa(64 * mib), + diagnostics.KeyMaxUncompressedBytes: strconv.Itoa(128 * mib), + diagnostics.KeyMaxBytesPerUser: strconv.Itoa(200 * mib), + }} + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(fmt.Sprintf( + `{"values":{"%s":"%d","%s":"%d"}}`, + diagnostics.KeyMaxBundleBytes, + 50*mib, + diagnostics.KeyMaxUncompressedBytes, + 60*mib, + )), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 1 { + t.Fatalf("valid diagnostics batch SetMany=%d, want 1", settings.setManyCalls) + } + }) +} + +func TestAdminGenericSettingsRoutesRejectUnsafeRateLimitValues(t *testing.T) { + t.Run("batch", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{}} + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"ratelimit.global.requests_per_second":"1e308"}}`), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 0 || settings.setCalls != 0 { + t.Fatalf("invalid rate wrote settings: SetMany=%d Set=%d", settings.setManyCalls, settings.setCalls) + } + }) + + t.Run("single", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{}} + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings/ratelimit.global.requests_per_second", + strings.NewReader(`{"value":"1e308"}`), + ) + req = withChiParam(req, "key", "ratelimit.global.requests_per_second") + rec := httptest.NewRecorder() + + handler.HandleUpdateSetting(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 0 || settings.setCalls != 0 { + t.Fatalf("invalid rate wrote settings: SetMany=%d Set=%d", settings.setManyCalls, settings.setCalls) + } + }) +} + +func TestAdminSettingsRejectClearingOnlyRedisTransport(t *testing.T) { + newStore := func() *fakeServerSettingsStore { + return &fakeServerSettingsStore{values: map[string]string{ + "ratelimit.backend": "redis", + "redis.url": "redis://cache.example.invalid:6379", + }} + } + + t.Run("batch", func(t *testing.T) { + settings := newStore() + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"redis.url":""}}`), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 0 || settings.values["redis.url"] == "" { + t.Fatalf("invalid clear was persisted: calls=%d values=%#v", settings.setManyCalls, settings.values) + } + }) + + t.Run("single", func(t *testing.T) { + settings := newStore() + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings/redis.url", + strings.NewReader(`{"value":""}`), + ) + req = withChiParam(req, "key", "redis.url") + rec := httptest.NewRecorder() + + handler.HandleUpdateSetting(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if settings.setCalls != 0 || settings.values["redis.url"] == "" { + t.Fatalf("invalid clear was persisted: calls=%d values=%#v", settings.setCalls, settings.values) + } + }) + + t.Run("bootstrap Sentinel", func(t *testing.T) { + settings := newStore() + handler := &AdminHandler{ + SettingsRepo: settings, + RedisBootstrapAvailable: true, + } + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"redis.url":""}}`), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 1 || settings.values["redis.url"] != "" { + t.Fatalf("clear with bootstrap transport was not persisted: calls=%d values=%#v", settings.setManyCalls, settings.values) + } + }) +} + +func TestAdminSettingsRejectMalformedRedisURL(t *testing.T) { + for _, tc := range []struct { + name string + target string + body string + single bool + }{ + {name: "batch", target: "/admin/settings", body: `{"values":{"redis.url":"not-a-url"}}`}, + {name: "single", target: "/admin/settings/redis.url", body: `{"value":"not-a-url"}`, single: true}, + } { + t.Run(tc.name, func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{}} + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest(http.MethodPut, tc.target, strings.NewReader(tc.body)) + if tc.single { + req = withChiParam(req, "key", "redis.url") + } + rec := httptest.NewRecorder() + + if tc.single { + handler.HandleUpdateSetting(rec, req) + } else { + handler.HandleUpdateSettings(rec, req) + } + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 0 || settings.setCalls != 0 { + t.Fatalf("malformed Redis URL was persisted: SetMany=%d Set=%d", settings.setManyCalls, settings.setCalls) + } + }) + } +} + +func TestAdminUpdateSettingsSkipsFunctionalNoOp(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{}} + handler := &AdminHandler{SettingsRepo: settings, RestartStatus: NewServerRestartStatusTracker()} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"database.max_connections":"20"}}`), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 0 { + t.Fatalf("SetMany calls = %d, want 0 for an effective-default no-op", settings.setManyCalls) + } + var response updateSettingsResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.RestartRequired || len(response.RestartRequiredKeys) != 0 { + t.Fatalf("no-op response requested restart: %#v", response) + } +} + +func TestAdminUpdateSettingsPersistsClearWhenOverrideEqualsDefault(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{"branding.server_name": "Silo"}} + handler := &AdminHandler{SettingsRepo: settings, RestartStatus: NewServerRestartStatusTracker()} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"branding.server_name":""}}`), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 1 { + t.Fatalf("SetMany calls = %d, want 1 for explicit clear", settings.setManyCalls) + } + if settings.values["branding.server_name"] != "" { + t.Fatalf("stored value = %q, want cleared override", settings.values["branding.server_name"]) + } + var response updateSettingsResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Values["branding.server_name"] != "Silo" || response.RestartRequired { + t.Fatalf("response = %#v, want unchanged effective default without restart", response) + } +} + +func TestAdminUpdateSettingsReturnsEffectiveDefaultAfterClear(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{"branding.server_name": "Casa"}} + var callbackValue string + handler := &AdminHandler{ + SettingsRepo: settings, + OnServerSettingUpdated: func(_ context.Context, _ string, value string) { + callbackValue = value + }, + } + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"branding.server_name":""}}`), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var response updateSettingsResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatal(err) + } + if response.Values["branding.server_name"] != "Silo" { + t.Fatalf("response values = %#v, want effective default", response.Values) + } + if settings.values["branding.server_name"] != "" { + t.Fatalf("stored value = %q, want cleared override", settings.values["branding.server_name"]) + } + if callbackValue != "Silo" { + t.Fatalf("callback value = %q, want effective default Silo", callbackValue) + } +} + +func TestAdminUpdateSettingReturnsEffectiveDefaultAfterClear(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{"branding.server_name": "Casa"}} + var callbackValue string + handler := &AdminHandler{ + SettingsRepo: settings, + OnServerSettingUpdated: func(_ context.Context, _ string, value string) { + callbackValue = value + }, + } + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings/branding.server_name", + strings.NewReader(`{"value":""}`), + ) + req = withChiParam(req, "key", "branding.server_name") + rec := httptest.NewRecorder() + + handler.HandleUpdateSetting(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var response adminSettingResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatal(err) + } + if response.Value != "Silo" { + t.Fatalf("response value = %q, want effective default", response.Value) + } + if callbackValue != "Silo" { + t.Fatalf("callback value = %q, want effective default Silo", callbackValue) + } +} + +func TestAdminUpdateSettingPersistsClearWhenOverrideEqualsDefault(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{"branding.server_name": "Silo"}} + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings/branding.server_name", + strings.NewReader(`{"value":""}`), + ) + req = withChiParam(req, "key", "branding.server_name") + rec := httptest.NewRecorder() + + handler.HandleUpdateSetting(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 1 { + t.Fatalf("atomic writes = %d, want 1 for explicit clear", settings.setManyCalls) + } + if settings.values["branding.server_name"] != "" { + t.Fatalf("stored value = %q, want cleared override", settings.values["branding.server_name"]) + } + var response adminSettingResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Value != "Silo" || response.RestartRequired { + t.Fatalf("response = %#v, want unchanged effective default without restart", response) + } +} + +func TestAdminUpdateSettingPreservesLegacyPairedWriteFlow(t *testing.T) { + for _, tc := range []struct { + name string + initial map[string]string + key string + value string + }{ + { + name: "establish first half of pair", + key: "s3.public_endpoint", + value: "https://s3.example.invalid", + }, + { + name: "unrelated update with legacy partial pair", + initial: map[string]string{ + "s3.public_endpoint": "https://s3.example.invalid", + }, + key: "branding.server_name", + value: "Casa", + }, + { + name: "clear first half of pair", + initial: map[string]string{ + "watchsync.trakt.client_id": "configured-client-id", + "watchsync.trakt.client_secret": "clawrouter-e2e-secret", + }, + key: "watchsync.trakt.client_id", + value: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + settings := &fakeServerSettingsStore{values: tc.initial} + if settings.values == nil { + settings.values = map[string]string{} + } + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings/"+tc.key, + strings.NewReader(`{"value":"`+tc.value+`"}`), + ) + req = withChiParam(req, "key", tc.key) + rec := httptest.NewRecorder() + + handler.HandleUpdateSetting(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values[tc.key] != tc.value { + t.Fatalf("stored %s = %q, want %q", tc.key, settings.values[tc.key], tc.value) + } + }) + } +} + func (f *fakeServerSettingsStore) GetAll(context.Context) (map[string]string, error) { cloned := make(map[string]string, len(f.values)) for key, value := range f.values { @@ -52,6 +734,45 @@ func (f *fakeServerSettingsStore) GetAll(context.Context) (map[string]string, er type fakeS3SettingsCheckClient struct { headBucket func(ctx context.Context, bucket string) error + putObject func(ctx context.Context, bucket, key string, data []byte) error + getObject func(ctx context.Context, bucket, key string) ([]byte, error) + delete func(ctx context.Context, bucket, key string) error + objects map[string][]byte +} + +func (f *fakeS3SettingsCheckClient) PutObject( + ctx context.Context, + bucket, + key string, + data []byte, +) error { + if f.putObject != nil { + return f.putObject(ctx, bucket, key, data) + } + if f.objects == nil { + f.objects = make(map[string][]byte) + } + f.objects[key] = append([]byte(nil), data...) + return nil +} + +func (f *fakeS3SettingsCheckClient) GetObject( + ctx context.Context, + bucket, + key string, +) ([]byte, error) { + if f.getObject != nil { + return f.getObject(ctx, bucket, key) + } + return append([]byte(nil), f.objects[key]...), nil +} + +func (f *fakeS3SettingsCheckClient) DeleteObject(ctx context.Context, bucket, key string) error { + if f.delete != nil { + return f.delete(ctx, bucket, key) + } + delete(f.objects, key) + return nil } func (f *fakeS3SettingsCheckClient) HeadBucket(ctx context.Context, bucket string) error { @@ -61,6 +782,70 @@ func (f *fakeS3SettingsCheckClient) HeadBucket(ctx context.Context, bucket strin return nil } +func TestCheckS3ObjectPermissionsCleansUpAfterAmbiguousPutFailure(t *testing.T) { + client := &fakeS3SettingsCheckClient{} + client.putObject = func(_ context.Context, _, key string, data []byte) error { + if client.objects == nil { + client.objects = make(map[string][]byte) + } + client.objects[key] = append([]byte(nil), data...) + return errors.New("response lost") + } + + err := checkS3ObjectPermissions(context.Background(), client, "silo") + + if err == nil || !strings.Contains(err.Error(), "write probe object: response lost") { + t.Fatalf("error = %v, want ambiguous write failure", err) + } + if len(client.objects) != 0 { + t.Fatalf("probe objects = %#v, want ambiguous write cleaned up", client.objects) + } +} + +func TestCheckS3ObjectPermissionsUsesFreshContextForFailureCleanup(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + var cleanupContextErr error + client := &fakeS3SettingsCheckClient{ + getObject: func(context.Context, string, string) ([]byte, error) { + cancel() + return nil, context.Canceled + }, + delete: func(ctx context.Context, _, _ string) error { + cleanupContextErr = ctx.Err() + return nil + }, + } + + err := checkS3ObjectPermissions(ctx, client, "silo") + + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context cancellation", err) + } + if cleanupContextErr != nil { + t.Fatalf("cleanup context error = %v, want live cleanup context", cleanupContextErr) + } +} + +func TestCheckS3ObjectPermissionsSurfacesCleanupFailure(t *testing.T) { + client := &fakeS3SettingsCheckClient{ + getObject: func(context.Context, string, string) ([]byte, error) { + return nil, errors.New("read failed") + }, + delete: func(context.Context, string, string) error { + return errors.New("delete failed") + }, + } + + err := checkS3ObjectPermissions(context.Background(), client, "silo") + + if err == nil || !strings.Contains(err.Error(), "cleanup probe object: delete failed") { + t.Fatalf("error = %v, want cleanup failure", err) + } + if !strings.Contains(err.Error(), "read probe object: read failed") { + t.Fatalf("error = %v, want original read failure", err) + } +} + type fakeRedisSettingsCheckClient struct { ping func(ctx context.Context) error } @@ -80,6 +865,17 @@ type fakeEmbeddingsSettingsCheckClient struct { embed func(ctx context.Context, texts []string) ([][]float32, error) } +type fakeMDBListSettingsCheckClient struct { + check func(context.Context) error +} + +func (f *fakeMDBListSettingsCheckClient) Check(ctx context.Context) error { + if f.check != nil { + return f.check(ctx) + } + return nil +} + func (f *fakeEmbeddingsSettingsCheckClient) Embed( ctx context.Context, texts []string, @@ -154,6 +950,53 @@ func TestHandleCheckSettingsConnectionS3UsesPersistedSensitiveValues(t *testing. } } +func TestHandleCheckSettingsConnectionMDBListUsesDraftOrSavedKey(t *testing.T) { + originalFactory := newAdminMDBListSettingsCheckClient + t.Cleanup(func() { newAdminMDBListSettingsCheckClient = originalFactory }) + var captured []string + newAdminMDBListSettingsCheckClient = func(apiKey string) mdblistSettingsCheckClient { + captured = append(captured, apiKey) + return &fakeMDBListSettingsCheckClient{} + } + handler := &AdminHandler{SettingsRepo: &fakeServerSettingsStore{values: map[string]string{ + "mdblist.api_key": "saved-key", + }}} + + for _, tc := range []struct { + name string + body map[string]any + want string + }{ + { + name: "saved key when draft is blank and untouched", + body: map[string]any{"values": map[string]string{"mdblist.api_key": ""}, "dirty_keys": []string{}}, + want: "saved-key", + }, + { + name: "unsaved draft key", + body: map[string]any{"values": map[string]string{"mdblist.api_key": "draft-key"}, "dirty_keys": []string{"mdblist.api_key"}}, + want: "draft-key", + }, + } { + t.Run(tc.name, func(t *testing.T) { + rec := performSettingsCheckRequest(t, handler, "/admin/settings/check/mdblist", tc.body) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var response connectionCheckResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatal(err) + } + if !response.Success { + t.Fatalf("connection check failed: %s", response.Message) + } + if got := captured[len(captured)-1]; got != tc.want { + t.Fatalf("factory API key = %q, want %q", got, tc.want) + } + }) + } +} + func TestHandleCheckSettingsConnectionRedisHonorsExplicitClear(t *testing.T) { handler := &AdminHandler{ SettingsRepo: &fakeServerSettingsStore{ @@ -306,6 +1149,9 @@ func TestAdminUpdateSettingRedactsSensitiveSetting(t *testing.T) { settings := &fakeServerSettingsStore{} handler := &AdminHandler{SettingsRepo: settings} + providerPrefix := strings.TrimSuffix(key, ".client_secret") + settings.values = map[string]string{providerPrefix + ".client_id": "configured-client-id"} + req := httptest.NewRequest( http.MethodPut, "/admin/settings/"+key, @@ -353,7 +1199,11 @@ func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) { } for _, tc := range cases { t.Run(tc.key, func(t *testing.T) { - settings := &fakeServerSettingsStore{} + initial := map[string]string{} + if tc.key == "s3.public_bucket" { + initial["s3.public_endpoint"] = "https://s3.example.test" + } + settings := &fakeServerSettingsStore{values: initial} restartStatus := NewServerRestartStatusTracker() handler := &AdminHandler{SettingsRepo: settings, RestartStatus: restartStatus} @@ -437,8 +1287,8 @@ func TestAdminUpdateCatalogSearchSemanticSettingsValidation(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } - if settings.values[tc.key] != tc.want { - t.Fatalf("stored value = %q, want %q", settings.values[tc.key], tc.want) + if got := config.EffectiveAdminSettings(settings.values)[tc.key]; got != tc.want { + t.Fatalf("effective value = %q, want %q", got, tc.want) } }) } diff --git a/internal/api/handlers/admin_settings_legacy_pairs_test.go b/internal/api/handlers/admin_settings_legacy_pairs_test.go new file mode 100644 index 00000000..f9a7de02 --- /dev/null +++ b/internal/api/handlers/admin_settings_legacy_pairs_test.go @@ -0,0 +1,428 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestAdminUpdateSettingsGrandfathersUntouchedLegacyInvalidSettings(t *testing.T) { + tests := []struct { + name string + initial map[string]string + }{ + { + name: "auth expiry relationship", + initial: map[string]string{ + "auth.access_token_expiry": "48h", + "auth.refresh_token_expiry": "24h", + }, + }, + { + name: "playback threshold relationship", + initial: map[string]string{ + "playback.watched_threshold": "90", + "playback.min_resume_threshold": "95", + }, + }, + { + name: "S3 pair", + initial: map[string]string{ + "s3.public_endpoint": "https://s3.example.invalid", + }, + }, + { + name: "S3 public URL auth relationship", + initial: map[string]string{ + "s3.public_url_auth": "public", + }, + }, + { + name: "email prerequisites", + initial: map[string]string{ + "email.enabled": "true", + }, + }, + { + name: "watchsync pair", + initial: map[string]string{ + "watchsync.trakt.client_id": "configured-client-id", + }, + }, + { + name: "Redis transport relationship", + initial: map[string]string{ + "ratelimit.backend": "redis", + }, + }, + { + name: "catalog value", + initial: map[string]string{ + "catalog.search.provider": "legacy-invalid-provider", + }, + }, + { + name: "download period relationship", + initial: map[string]string{ + "download.max_per_period": "5", + "download.period_duration": "invalid-duration", + }, + }, + { + name: "matcher legacy fallback", + initial: map[string]string{ + "matcher.enable_tv_series_root_queue": "false", + "matcher.enable_tv_series_group_queue": "invalid-bool", + }, + }, + { + name: "AI concurrency legacy fallback", + initial: map[string]string{ + "ai.max_concurrent_jobs": "0", + "subtitle_ai.max_concurrent_jobs": "invalid-integer", + }, + }, + { + name: "S3 path-style legacy fallback", + initial: map[string]string{ + "s3.operational_path_style": "invalid-bool", + }, + }, + { + name: "S3 token TTL legacy fallback", + initial: map[string]string{ + "s3.operational_token_ttl": "invalid-integer", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + settings := &fakeServerSettingsStore{values: tc.initial} + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"branding.server_name":"Casa"}}`), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["branding.server_name"] != "Casa" { + t.Fatalf("branding.server_name = %q, want Casa", settings.values["branding.server_name"]) + } + }) + } +} + +func TestAdminUpdateSettingsRequiresTouchedLegacyRelationshipsToBeValid(t *testing.T) { + tests := []struct { + name string + initial map[string]string + body string + }{ + { + name: "auth expiry relationship", + initial: map[string]string{ + "auth.access_token_expiry": "48h", + "auth.refresh_token_expiry": "24h", + }, + body: `{"values":{"auth.access_token_expiry":"72h"}}`, + }, + { + name: "playback threshold relationship", + initial: map[string]string{ + "playback.watched_threshold": "90", + "playback.min_resume_threshold": "95", + }, + body: `{"values":{"playback.watched_threshold":"80"}}`, + }, + { + name: "S3 pair", + initial: map[string]string{ + "s3.public_endpoint": "https://old-s3.example.invalid", + }, + body: `{"values":{"s3.public_endpoint":"https://new-s3.example.invalid"}}`, + }, + { + name: "S3 public URL auth relationship", + initial: map[string]string{ + "s3.public_url_auth": "public", + }, + body: `{"values":{"s3.public_token_secret":"configured-token"}}`, + }, + { + name: "email prerequisites", + initial: map[string]string{ + "email.enabled": "true", + }, + body: `{"values":{"email.smtp_host":"smtp.example.invalid"}}`, + }, + { + name: "watchsync pair", + initial: map[string]string{ + "watchsync.trakt.client_id": "old-client-id", + }, + body: `{"values":{"watchsync.trakt.client_id":"new-client-id"}}`, + }, + { + name: "Redis transport relationship", + initial: map[string]string{ + "ratelimit.backend": "redis", + }, + body: `{"values":{"redis.url":""}}`, + }, + { + name: "catalog value", + initial: map[string]string{ + "catalog.search.provider": "legacy-invalid-provider", + }, + body: `{"values":{"catalog.search.provider":"still-invalid"}}`, + }, + { + name: "download period relationship", + initial: map[string]string{ + "download.max_per_period": "5", + "download.period_duration": "invalid-duration", + }, + body: `{"values":{"download.max_per_period":"6"}}`, + }, + { + name: "matcher legacy fallback", + initial: map[string]string{ + "matcher.enable_tv_series_root_queue": "false", + "matcher.enable_tv_series_group_queue": "invalid-bool", + }, + body: `{"values":{"matcher.enable_tv_series_root_queue":"false"}}`, + }, + { + name: "S3 path-style legacy fallback", + initial: map[string]string{ + "s3.operational_path_style": "true", + }, + body: `{"values":{"s3.operational_path_style":"invalid-bool"}}`, + }, + { + name: "S3 token TTL legacy fallback", + initial: map[string]string{ + "s3.operational_token_ttl": "3600", + }, + body: `{"values":{"s3.operational_token_ttl":"invalid-integer"}}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + settings := &fakeServerSettingsStore{values: tc.initial} + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest(http.MethodPut, "/admin/settings", strings.NewReader(tc.body)) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 0 { + t.Fatalf("SetMany calls = %d, want 0", settings.setManyCalls) + } + }) + } +} + +func TestAdminUpdateSettingsDoesNotHideNewInvalidRelationshipBehindGrandfatheredState(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{ + "s3.public_endpoint": "https://s3.example.invalid", + }} + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"watchsync.trakt.client_id":"new-client-id"}}`), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 0 { + t.Fatalf("SetMany calls = %d, want 0", settings.setManyCalls) + } +} + +func TestAdminUpdateSettingsCanRepairLegacyInvalidRelationship(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{ + "auth.access_token_expiry": "48h", + "auth.refresh_token_expiry": "24h", + }} + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{"auth.refresh_token_expiry":"72h"}}`), + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["auth.refresh_token_expiry"] != "72h" { + t.Fatalf("auth.refresh_token_expiry = %q, want 72h", settings.values["auth.refresh_token_expiry"]) + } +} + +func TestAdminUpdateSettingsValidatesResolvedLegacyS3Values(t *testing.T) { + tests := []struct { + name string + initial map[string]string + body string + }{ + { + name: "canonical endpoint with legacy bucket", + initial: map[string]string{ + "s3.operational_bucket": "legacy-bucket", + }, + body: `{"values":{"s3.public_endpoint":"https://s3.example.invalid"}}`, + }, + { + name: "legacy endpoint with unchanged legacy bucket", + initial: map[string]string{ + "s3.operational_bucket": "legacy-bucket", + }, + body: `{"values":{"s3.operational_endpoint":"https://s3.example.invalid"}}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + settings := &fakeServerSettingsStore{values: tc.initial} + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest(http.MethodPut, "/admin/settings", strings.NewReader(tc.body)) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.setManyCalls != 1 { + t.Fatalf("SetMany calls = %d, want 1", settings.setManyCalls) + } + }) + } +} + +func TestAdminUpdateSettingsExplicitS3ValueDoesNotPullOperationalFallback(t *testing.T) { + tests := []struct { + name string + initial map[string]string + body string + key string + want string + }{ + { + name: "public path style", + initial: map[string]string{ + "s3.operational_path_style": "legacy-invalid-bool", + "s3.private_path_style": "also-invalid-bool", + }, + body: `{"values":{"s3.public_path_style":"false"}}`, + key: "s3.public_path_style", + want: "false", + }, + { + name: "private path style", + initial: map[string]string{ + "s3.operational_path_style": "legacy-invalid-bool", + "s3.public_path_style": "also-invalid-bool", + }, + body: `{"values":{"s3.private_path_style":"false"}}`, + key: "s3.private_path_style", + want: "false", + }, + { + name: "public token TTL", + initial: map[string]string{ + "s3.operational_token_ttl": "legacy-invalid-integer", + }, + body: `{"values":{"s3.public_token_ttl":"7200"}}`, + key: "s3.public_token_ttl", + want: "7200", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + settings := &fakeServerSettingsStore{values: tc.initial} + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest(http.MethodPut, "/admin/settings", strings.NewReader(tc.body)) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values[tc.key] != tc.want { + t.Fatalf("%s = %q, want %q", tc.key, settings.values[tc.key], tc.want) + } + }) + } +} + +func TestAdminUpdateSettingsShadowedOperationalS3ValueDoesNotBlockSave(t *testing.T) { + tests := []struct { + name string + initial map[string]string + body string + key string + want string + }{ + { + name: "path style", + initial: map[string]string{ + "s3.public_path_style": "false", + "s3.private_path_style": "true", + }, + body: `{"values":{"s3.operational_path_style":"invalid-but-shadowed"}}`, + key: "s3.operational_path_style", + want: "invalid-but-shadowed", + }, + { + name: "token TTL", + initial: map[string]string{ + "s3.public_token_ttl": "7200", + }, + body: `{"values":{"s3.operational_token_ttl":"invalid-but-shadowed"}}`, + key: "s3.operational_token_ttl", + want: "invalid-but-shadowed", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + settings := &fakeServerSettingsStore{values: tc.initial} + handler := &AdminHandler{SettingsRepo: settings} + req := httptest.NewRequest(http.MethodPut, "/admin/settings", strings.NewReader(tc.body)) + rec := httptest.NewRecorder() + + handler.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values[tc.key] != tc.want { + t.Fatalf("%s = %q, want %q", tc.key, settings.values[tc.key], tc.want) + } + }) + } +} diff --git a/internal/api/handlers/admin_subtitles.go b/internal/api/handlers/admin_subtitles.go index 37c55484..94418139 100644 --- a/internal/api/handlers/admin_subtitles.go +++ b/internal/api/handlers/admin_subtitles.go @@ -5,7 +5,9 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" + "sync" "time" "github.com/Silo-Server/silo-server/internal/subtitles" @@ -20,10 +22,11 @@ type SubtitleProviderFactory func(cfg *subtitles.ProviderConfig) (subtitles.Prov // AdminSubtitleHandler handles admin operations for subtitle provider management. type AdminSubtitleHandler struct { - repo subtitles.Repository - manager *subtitles.Manager - pool *pgxpool.Pool - providerFactory SubtitleProviderFactory + repo subtitles.Repository + manager *subtitles.Manager + pool *pgxpool.Pool + providerFactory SubtitleProviderFactory + providerReloadMu sync.Mutex } // NewAdminSubtitleHandler creates a new AdminSubtitleHandler. @@ -35,10 +38,15 @@ func NewAdminSubtitleHandler(repo subtitles.Repository) *AdminSubtitleHandler { } type updateSubtitleProviderRequest struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - Username string `json:"username"` - Password string `json:"password"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + Username string `json:"username"` + Password string `json:"password"` + ClearCredentials bool `json:"clear_credentials"` +} + +type subtitleProviderCredentialClearer interface { + ClearProviderCredentials(ctx context.Context, providerName string) error } var builtinSubtitleProviders = []string{"opensubtitles", "subdl", "subsource"} @@ -61,12 +69,49 @@ func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *ht writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name") return } + knownProvider := knownSubtitleProvider(providerName) var req updateSubtitleProviderRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body") return } + if req.ClearCredentials { + clearer, ok := h.repo.(subtitleProviderCredentialClearer) + if !ok { + writeError(w, http.StatusInternalServerError, "update_error", "Provider credential clearing is not supported") + return + } + if err := clearer.ClearProviderCredentials(r.Context(), providerName); err != nil { + writeError(w, http.StatusInternalServerError, "update_error", "Failed to clear provider credentials") + return + } + if knownProvider { + if err := h.reloadProviderFromRepository(r.Context(), providerName); err != nil { + writeError(w, http.StatusInternalServerError, "update_error", "Credentials cleared but latest provider config could not be applied") + return + } + } + writeJSON(w, http.StatusOK, map[string]interface{}{ //nolint:goconst // JSON response keys stay inline. + "status": "ok", "applied_live": knownProvider && h.manager != nil, + }) + return + } + + stored, err := h.repo.GetProviderConfig(r.Context(), providerName) + if err != nil { + writeError(w, http.StatusInternalServerError, "update_error", "Failed to load provider config") + return + } + req = preserveSubtitleProviderFields(stored, req) + + if req.Enabled && knownProvider { + _, err = h.providerFactory(subtitleProviderConfigFromRequest(providerName, req)) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_provider_config", err.Error()) + return + } + } cfg := &subtitles.ProviderConfig{ ProviderName: providerName, @@ -81,7 +126,48 @@ func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *ht return } - writeJSON(w, http.StatusOK, map[string]interface{}{"status": "ok"}) + if knownProvider { + if err := h.reloadProviderFromRepository(r.Context(), providerName); err != nil { + writeError(w, http.StatusInternalServerError, "update_error", "Provider saved but latest config could not be applied") + return + } + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "ok", "applied_live": knownProvider && h.manager != nil, + }) +} + +// reloadProviderFromRepository serializes the read-and-apply phase and always +// rebuilds from durable state. A concurrent request may commit after this +// request, but its own reload cannot be overtaken by an older request applying +// request-local state afterward. +func (h *AdminSubtitleHandler) reloadProviderFromRepository(ctx context.Context, providerName string) error { + if h.manager == nil { + return nil + } + + h.providerReloadMu.Lock() + defer h.providerReloadMu.Unlock() + + cfg, err := h.repo.GetProviderConfig(ctx, providerName) + if err != nil { + return fmt.Errorf("load latest provider config: %w", err) + } + + var provider subtitles.Provider + if cfg != nil && cfg.Enabled { + provider, err = h.providerFactory(cfg) + if err != nil { + return fmt.Errorf("create provider from latest config: %w", err) + } + } + + h.manager.RemoveProvider(providerName) + if provider != nil { + h.manager.RegisterProvider(provider) + } + return nil } // HandleTestProvider handles POST /api/v1/admin/subtitle-providers/{provider}/test @@ -92,19 +178,27 @@ func (h *AdminSubtitleHandler) HandleTestProvider(w http.ResponseWriter, r *http return } - cfg, err := h.repo.GetProviderConfig(r.Context(), providerName) + var req updateSubtitleProviderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { + writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body") + return + } + + stored, err := h.repo.GetProviderConfig(r.Context(), providerName) if err != nil { writeJSON(w, http.StatusOK, map[string]interface{}{ "success": false, "error": fmt.Sprintf("Failed to load config: %v", err), }) return } - if cfg == nil { + if stored == nil && req.APIKey == "" && req.Username == "" && req.Password == "" { writeJSON(w, http.StatusOK, map[string]interface{}{ - "success": false, "error": "Provider not found", + "success": false, "error": "Provider credentials are not configured", }) return } + req = preserveSubtitleProviderFields(stored, req) + cfg := subtitleProviderConfigFromRequest(providerName, req) provider, err := h.providerFactory(cfg) if err != nil { @@ -142,6 +236,40 @@ func (h *AdminSubtitleHandler) HandleTestProvider(w http.ResponseWriter, r *http }) } +func preserveSubtitleProviderFields(stored *subtitles.ProviderConfig, req updateSubtitleProviderRequest) updateSubtitleProviderRequest { + if stored == nil { + return req + } + preserveEmpty := func(draft *string, current string) { + if *draft == "" { + *draft = current + } + } + preserveEmpty(&req.APIKey, stored.APIKey) + preserveEmpty(&req.Username, stored.Username) + preserveEmpty(&req.Password, stored.Password) + return req +} + +func subtitleProviderConfigFromRequest(providerName string, req updateSubtitleProviderRequest) *subtitles.ProviderConfig { + return &subtitles.ProviderConfig{ + ProviderName: providerName, + Enabled: req.Enabled, + APIKey: req.APIKey, + Username: req.Username, + Password: req.Password, + } +} + +func knownSubtitleProvider(name string) bool { + for _, providerName := range builtinSubtitleProviders { + if name == providerName { + return true + } + } + return false +} + func defaultProviderFactory(cfg *subtitles.ProviderConfig) (subtitles.Provider, error) { switch cfg.ProviderName { case "opensubtitles": diff --git a/internal/api/handlers/admin_subtitles_config_test.go b/internal/api/handlers/admin_subtitles_config_test.go new file mode 100644 index 00000000..2ea6de19 --- /dev/null +++ b/internal/api/handlers/admin_subtitles_config_test.go @@ -0,0 +1,361 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/Silo-Server/silo-server/internal/subtitles" +) + +type adminSubtitleConfigRepo struct { + *handlerMockSubtitleRepo + mu sync.Mutex + configs map[string]subtitles.ProviderConfig +} + +func newAdminSubtitleConfigRepo() *adminSubtitleConfigRepo { + return &adminSubtitleConfigRepo{ + handlerMockSubtitleRepo: newMockSubtitleRepoForHandler(), + configs: make(map[string]subtitles.ProviderConfig), + } +} + +func (r *adminSubtitleConfigRepo) ListProviderConfigs(context.Context) ([]subtitles.ProviderConfig, error) { + r.mu.Lock() + defer r.mu.Unlock() + + configs := make([]subtitles.ProviderConfig, 0, len(r.configs)) + for _, cfg := range r.configs { + configs = append(configs, cfg) + } + return configs, nil +} + +func (r *adminSubtitleConfigRepo) GetProviderConfig(_ context.Context, name string) (*subtitles.ProviderConfig, error) { + r.mu.Lock() + defer r.mu.Unlock() + + cfg, ok := r.configs[name] + if !ok { + return nil, nil + } + return &cfg, nil +} + +func (r *adminSubtitleConfigRepo) UpsertProviderConfig(_ context.Context, cfg *subtitles.ProviderConfig) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.configs[cfg.ProviderName] = *cfg + return nil +} + +func (r *adminSubtitleConfigRepo) ClearProviderCredentials(_ context.Context, name string) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.configs[name] = subtitles.ProviderConfig{ProviderName: name, Enabled: false} + return nil +} + +func (r *adminSubtitleConfigRepo) providerConfig(name string) subtitles.ProviderConfig { + r.mu.Lock() + defer r.mu.Unlock() + return r.configs[name] +} + +type blockedSubtitleUpsertRepo struct { + *adminSubtitleConfigRepo + upsertPersisted chan struct{} + releaseUpsert chan struct{} +} + +func (r *blockedSubtitleUpsertRepo) UpsertProviderConfig(ctx context.Context, cfg *subtitles.ProviderConfig) error { + if err := r.adminSubtitleConfigRepo.UpsertProviderConfig(ctx, cfg); err != nil { + return err + } + close(r.upsertPersisted) + select { + case <-r.releaseUpsert: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +type adminSubtitleTestProvider struct { + name string + key string +} + +func (p *adminSubtitleTestProvider) Name() string { return p.name } + +func subtitleTestCredential(kind string) string { return "test-only-" + kind } + +func (p *adminSubtitleTestProvider) Search(context.Context, subtitles.SearchRequest) ([]subtitles.SubtitleResult, error) { + if p.key == subtitleTestCredential("valid") { + return []subtitles.SubtitleResult{{ID: "result", Provider: p.name}}, nil + } + return nil, nil +} + +func (p *adminSubtitleTestProvider) Download(context.Context, string) ([]byte, subtitles.SubtitleFormat, error) { + return []byte("subtitle"), subtitles.FormatSRT, nil +} + +func newTestableAdminSubtitleHandler(repo *adminSubtitleConfigRepo) (*AdminSubtitleHandler, *subtitles.Manager) { + manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test") + handler := NewAdminSubtitleHandler(repo) + handler.SetDownloadedSubtitleDeps(nil, manager) + handler.providerFactory = func(cfg *subtitles.ProviderConfig) (subtitles.Provider, error) { + return &adminSubtitleTestProvider{name: cfg.ProviderName, key: cfg.APIKey}, nil + } + return handler, manager +} + +func newTestableAdminSubtitleHandlerWithRepo( + repo subtitles.Repository, +) (*AdminSubtitleHandler, *subtitles.Manager) { + manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test") + handler := NewAdminSubtitleHandler(repo) + handler.SetDownloadedSubtitleDeps(nil, manager) + handler.providerFactory = func(cfg *subtitles.ProviderConfig) (subtitles.Provider, error) { + return &adminSubtitleTestProvider{name: cfg.ProviderName, key: cfg.APIKey}, nil + } + return handler, manager +} + +func TestUpdateSubtitleProviderAppliesSavedDraftLive(t *testing.T) { + repo := newAdminSubtitleConfigRepo() + repo.configs["subdl"] = subtitles.ProviderConfig{ + ProviderName: "subdl", Enabled: true, APIKey: subtitleTestCredential("old"), + } + handler, manager := newTestableAdminSubtitleHandler(repo) + + body, _ := json.Marshal(updateSubtitleProviderRequest{ + Enabled: true, + APIKey: subtitleTestCredential("valid"), + }) + req := withSubtitleRouteParam( + newAdminSubtitleRequest(http.MethodPut, "/admin/subtitle-providers/subdl", body), + "provider", "subdl", + ) + rec := httptest.NewRecorder() + handler.HandleUpdateProvider(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := repo.providerConfig("subdl").APIKey; got != subtitleTestCredential("valid") { + t.Fatalf("saved API key = %q, want valid test credential", got) + } + results, err := manager.Search(context.Background(), subtitles.SearchRequest{Title: "Movie"}) + if err != nil || len(results.Results) != 1 { + t.Fatalf("live provider results = %#v, err = %v", results, err) + } +} + +func TestUpdateSubtitleProviderDisablesLiveProvider(t *testing.T) { + repo := newAdminSubtitleConfigRepo() + handler, manager := newTestableAdminSubtitleHandler(repo) + manager.RegisterProvider(&adminSubtitleTestProvider{ + name: "subdl", + key: subtitleTestCredential("valid"), + }) + + body, _ := json.Marshal(updateSubtitleProviderRequest{Enabled: false}) + req := withSubtitleRouteParam( + newAdminSubtitleRequest(http.MethodPut, "/admin/subtitle-providers/subdl", body), + "provider", "subdl", + ) + rec := httptest.NewRecorder() + handler.HandleUpdateProvider(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + results, err := manager.Search(context.Background(), subtitles.SearchRequest{Title: "Movie"}) + if err != nil || len(results.Results) != 0 { + t.Fatalf("disabled provider results = %#v, err = %v", results, err) + } +} + +func TestUpdateLegacySubtitleProviderPreservesV1Compatibility(t *testing.T) { + repo := newAdminSubtitleConfigRepo() + handler := NewAdminSubtitleHandler(repo) + body, _ := json.Marshal(updateSubtitleProviderRequest{ + Enabled: true, + APIKey: subtitleTestCredential("legacy"), + }) + req := withSubtitleRouteParam( + newAdminSubtitleRequest(http.MethodPut, "/admin/subtitle-providers/legacy-provider", body), + "provider", "legacy-provider", + ) + rec := httptest.NewRecorder() + + handler.HandleUpdateProvider(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) + } + if got := repo.providerConfig("legacy-provider"); !got.Enabled || + got.APIKey != subtitleTestCredential("legacy") { + t.Fatalf("stored legacy provider = %#v", got) + } + var response struct { + AppliedLive bool `json:"applied_live"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.AppliedLive { + t.Fatal("legacy provider reported as applied live") + } +} + +func TestLegacySubtitleProviderConnectionFailureKeepsHTTP200(t *testing.T) { + repo := newAdminSubtitleConfigRepo() + repo.configs["legacy-provider"] = subtitles.ProviderConfig{ + ProviderName: "legacy-provider", + Enabled: true, + APIKey: subtitleTestCredential("legacy"), + } + handler := NewAdminSubtitleHandler(repo) + req := withSubtitleRouteParam( + newAdminSubtitleRequest(http.MethodPost, "/admin/subtitle-providers/legacy-provider/test", nil), + "provider", "legacy-provider", + ) + rec := httptest.NewRecorder() + + handler.HandleTestProvider(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) + } + var response struct { + Success bool `json:"success"` + Error string `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Success || response.Error == "" { + t.Fatalf("response = %#v, want a 200 failure payload", response) + } +} + +func TestUpdateSubtitleProviderExplicitlyClearsCredentialsAndDisablesLiveProvider(t *testing.T) { + repo := newAdminSubtitleConfigRepo() + repo.configs["subdl"] = subtitles.ProviderConfig{ + ProviderName: "subdl", Enabled: true, APIKey: subtitleTestCredential("valid"), + } + handler, manager := newTestableAdminSubtitleHandler(repo) + manager.RegisterProvider(&adminSubtitleTestProvider{ + name: "subdl", + key: subtitleTestCredential("valid"), + }) + + body, _ := json.Marshal(updateSubtitleProviderRequest{Enabled: true, ClearCredentials: true}) + req := withSubtitleRouteParam( + newAdminSubtitleRequest(http.MethodPut, "/admin/subtitle-providers/subdl", body), + "provider", "subdl", + ) + rec := httptest.NewRecorder() + handler.HandleUpdateProvider(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := repo.providerConfig("subdl"); got.Enabled || got.APIKey != "" { + t.Fatalf("cleared config = %#v", got) + } + results, err := manager.Search(context.Background(), subtitles.SearchRequest{Title: "Movie"}) + if err != nil || len(results.Results) != 0 { + t.Fatalf("cleared live provider results = %#v, err = %v", results, err) + } +} + +func TestConcurrentSubtitlePutAndClearApplyLatestCommittedConfig(t *testing.T) { + baseRepo := newAdminSubtitleConfigRepo() + repo := &blockedSubtitleUpsertRepo{ + adminSubtitleConfigRepo: baseRepo, + upsertPersisted: make(chan struct{}), + releaseUpsert: make(chan struct{}), + } + handler, manager := newTestableAdminSubtitleHandlerWithRepo(repo) + + putBody, _ := json.Marshal(updateSubtitleProviderRequest{ + Enabled: true, + APIKey: subtitleTestCredential("valid"), + }) + putReq := withSubtitleRouteParam( + newAdminSubtitleRequest(http.MethodPut, "/admin/subtitle-providers/subdl", putBody), + "provider", "subdl", + ) + putRec := httptest.NewRecorder() + putDone := make(chan struct{}) + go func() { + defer close(putDone) + handler.HandleUpdateProvider(putRec, putReq) + }() + + <-repo.upsertPersisted + + clearBody, _ := json.Marshal(updateSubtitleProviderRequest{ClearCredentials: true}) + clearReq := withSubtitleRouteParam( + newAdminSubtitleRequest(http.MethodPut, "/admin/subtitle-providers/subdl", clearBody), + "provider", "subdl", + ) + clearRec := httptest.NewRecorder() + handler.HandleUpdateProvider(clearRec, clearReq) + if clearRec.Code != http.StatusOK { + t.Fatalf("clear status = %d, body = %s", clearRec.Code, clearRec.Body.String()) + } + + close(repo.releaseUpsert) + <-putDone + if putRec.Code != http.StatusOK { + t.Fatalf("PUT status = %d, body = %s", putRec.Code, putRec.Body.String()) + } + + if got := repo.providerConfig("subdl"); got.Enabled || got.APIKey != "" { + t.Fatalf("latest persisted config = %#v, want disabled with cleared credentials", got) + } + results, err := manager.Search(context.Background(), subtitles.SearchRequest{Title: "Movie"}) + if err != nil || len(results.Results) != 0 { + t.Fatalf("live provider results = %#v, err = %v; stale PUT config was applied", results, err) + } +} + +func TestSubtitleProviderConnectionTestUsesUnsavedDraft(t *testing.T) { + repo := newAdminSubtitleConfigRepo() + repo.configs["subdl"] = subtitles.ProviderConfig{ + ProviderName: "subdl", Enabled: true, APIKey: subtitleTestCredential("old"), + } + handler, _ := newTestableAdminSubtitleHandler(repo) + + body, _ := json.Marshal(updateSubtitleProviderRequest{APIKey: subtitleTestCredential("valid")}) + req := withSubtitleRouteParam( + newAdminSubtitleRequest(http.MethodPost, "/admin/subtitle-providers/subdl/test", body), + "provider", "subdl", + ) + rec := httptest.NewRecorder() + handler.HandleTestProvider(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var response struct { + Success bool `json:"success"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil || !response.Success { + t.Fatalf("response = %s, err = %v", rec.Body.String(), err) + } + if got := repo.providerConfig("subdl").APIKey; got != subtitleTestCredential("old") { + t.Fatalf("connection test persisted draft key %q", got) + } +} diff --git a/internal/api/handlers/branding.go b/internal/api/handlers/branding.go index db512aac..64df49e1 100644 --- a/internal/api/handlers/branding.go +++ b/internal/api/handlers/branding.go @@ -27,30 +27,32 @@ func NewBrandingHandler(svc *branding.Service) *BrandingHandler { // the v1 API rules. Asset URLs are stable, cache-bustable paths (empty when no // custom asset is set). type brandingResponse struct { - ServerName string `json:"server_name"` - LoginSubtitle string `json:"login_subtitle"` - AccentColor string `json:"accent_color,omitempty"` - DefaultTheme string `json:"default_theme,omitempty"` - WordmarkURL string `json:"wordmark_url,omitempty"` - MarkURL string `json:"mark_url,omitempty"` - FaviconURL string `json:"favicon_url,omitempty"` - LoginBgURL string `json:"login_bg_url,omitempty"` + ServerName string `json:"server_name"` + LoginSubtitle string `json:"login_subtitle"` + AccentColor string `json:"accent_color,omitempty"` + DefaultTheme string `json:"default_theme,omitempty"` + WordmarkURL string `json:"wordmark_url,omitempty"` + MarkURL string `json:"mark_url,omitempty"` + FaviconURL string `json:"favicon_url,omitempty"` + LoginBgURL string `json:"login_bg_url,omitempty"` + StorageAvailable bool `json:"storage_available"` } // HandleGetBranding returns the server branding configuration. Public endpoint — // no authentication required so branding applies before login (white-label). func (h *BrandingHandler) HandleGetBranding(w http.ResponseWriter, r *http.Request) { snap := h.svc.Load(r.Context()) - w.Header().Set("Cache-Control", "public, max-age=60") + w.Header().Set("Cache-Control", "no-store") writeJSON(w, http.StatusOK, brandingResponse{ - ServerName: snap.ServerName, - LoginSubtitle: snap.LoginSubtitle, - AccentColor: snap.AccentColor, - DefaultTheme: snap.DefaultTheme, - WordmarkURL: snap.AssetURL(branding.KindWordmark), - MarkURL: snap.AssetURL(branding.KindMark), - FaviconURL: snap.AssetURL(branding.KindFavicon), - LoginBgURL: snap.AssetURL(branding.KindLoginBg), + ServerName: snap.ServerName, + LoginSubtitle: snap.LoginSubtitle, + AccentColor: snap.AccentColor, + DefaultTheme: snap.DefaultTheme, + WordmarkURL: snap.AssetURL(branding.KindWordmark), + MarkURL: snap.AssetURL(branding.KindMark), + FaviconURL: snap.AssetURL(branding.KindFavicon), + LoginBgURL: snap.AssetURL(branding.KindLoginBg), + StorageAvailable: h.svc.HasStorage(), }) } diff --git a/internal/api/handlers/plugins.go b/internal/api/handlers/plugins.go index 51916b48..e6754fa6 100644 --- a/internal/api/handlers/plugins.go +++ b/internal/api/handlers/plugins.go @@ -94,8 +94,9 @@ type pluginInstallationUpdateRequest struct { } type pluginConfigRequest struct { - Key string `json:"key"` - Value map[string]any `json:"value"` + Key string `json:"key"` + Value map[string]any `json:"value"` + ClearSecrets []string `json:"clear_secrets,omitempty"` } type pluginAuthBindingRequest struct { @@ -292,8 +293,9 @@ type pluginAssetJSON struct { } type pluginConfigValueJSON struct { - Key string `json:"key"` - Value map[string]any `json:"value"` + Key string `json:"key"` + Value map[string]any `json:"value"` + ConfiguredSecrets []string `json:"configured_secrets,omitempty"` } type pluginAuthBindingJSON struct { @@ -986,7 +988,9 @@ func (h *PluginHandler) HandlePutInstallationConfig(w http.ResponseWriter, r *ht return } - if err := h.service.SetGlobalConfig(r.Context(), id, req.Key, req.Value); err != nil { + if err := h.service.SetGlobalConfigWithClears( + r.Context(), id, req.Key, req.Value, req.ClearSecrets, + ); err != nil { var validationErr *plugins.ConfigValidationError switch { case errors.As(err, &validationErr): @@ -1027,7 +1031,9 @@ func (h *PluginHandler) HandleTestInstallationConfig(w http.ResponseWriter, r *h return } - if err := h.service.TestGlobalConfig(r.Context(), id, req.Key, req.Value); err != nil { + if err := h.service.TestGlobalConfigWithClears( + r.Context(), id, req.Key, req.Value, req.ClearSecrets, + ); err != nil { if errors.Is(err, plugins.ErrInstallationNotFound) { writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") return @@ -1087,6 +1093,8 @@ func (h *PluginHandler) HandlePutAuthBinding(w http.ResponseWriter, r *http.Requ return } + h.restartStatus.MarkRequired("plugin_auth_binding") + w.Header().Set("X-Silo-Restart-Required", "true") w.WriteHeader(http.StatusNoContent) } @@ -1150,7 +1158,36 @@ func (h *PluginHandler) HandleDeleteInstallation(w http.ResponseWriter, r *http. return } + installation, err := h.installations.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, plugins.ErrInstallationNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation") + return + } + if installation.IsBuiltin() { + writeError(w, http.StatusConflict, "builtin_installation", "Built-in host providers cannot be uninstalled") + return + } + + stopped := false + if h.service != nil { + if err := h.service.Stop(id); err != nil && !errors.Is(err, pluginhost.ErrClientNotFound) { + slog.ErrorContext(r.Context(), "stopping plugin before uninstall", "component", "api", "installation_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to stop plugin installation") + return + } + stopped = true + } + if err := h.installations.Delete(r.Context(), id); err != nil { + if stopped && installation.Enabled { + if _, restartErr := h.service.Start(r.Context(), id); restartErr != nil { + slog.ErrorContext(r.Context(), "restarting plugin after failed uninstall", "component", "api", "installation_id", id, "error", restartErr) + } + } if errors.Is(err, plugins.ErrInstallationNotFound) { writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") return @@ -1461,7 +1498,7 @@ func (h *PluginHandler) buildInstallationResponseWithBindings( Routes: routes, Assets: assets, Metadata: metadata, - GlobalConfigs: configValuesToJSON(configs), + GlobalConfigs: configValuesToJSON(configs, manifest), AuthBindings: authBindingsForInstallation(installation.ID, authBindings), TaskBindings: taskBindingsForInstallation(installation.ID, taskBindings), CreatedAt: installation.CreatedAt, @@ -1695,20 +1732,53 @@ func assetsToJSON(assets []*pluginv1.PackagedAsset) []pluginAssetJSON { return response } -func configValuesToJSON(configs []*plugins.RuntimeConfig) []pluginConfigValueJSON { +func configValuesToJSON( + configs []*plugins.RuntimeConfig, + manifest *pluginv1.PluginManifest, +) []pluginConfigValueJSON { response := make([]pluginConfigValueJSON, 0, len(configs)) for _, config := range configs { if config == nil { continue } + value := make(map[string]any) + configuredSecrets := make([]string, 0) + if manifest == nil || !plugins.HasGlobalConfigSchema(manifest, config.Key) { + // Without a manifest there is no trustworthy sensitivity schema. + // A row can also outlive a renamed/removed schema after an upgrade; + // fail closed rather than returning a potentially secret object. + } else { + publicFields, secretFields := plugins.GlobalConfigFieldSets(manifest, config.Key) + for _, field := range publicFields { + if saved, ok := config.Value[field]; ok { + value[field] = saved + } + } + for _, field := range secretFields { + if saved, ok := config.Value[field]; ok && pluginSecretConfigured(saved) { + configuredSecrets = append(configuredSecrets, field) + } + } + } response = append(response, pluginConfigValueJSON{ - Key: config.Key, - Value: config.Value, + Key: config.Key, + Value: value, + ConfiguredSecrets: configuredSecrets, }) } return response } +func pluginSecretConfigured(value any) bool { + if value == nil { + return false + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) != "" + } + return true +} + func authBindingsForInstallation(installationID int, bindings []*plugins.AuthBinding) []pluginAuthBindingJSON { response := make([]pluginAuthBindingJSON, 0) for _, binding := range bindings { diff --git a/internal/api/handlers/plugins_config_redaction_test.go b/internal/api/handlers/plugins_config_redaction_test.go new file mode 100644 index 00000000..bf571942 --- /dev/null +++ b/internal/api/handlers/plugins_config_redaction_test.go @@ -0,0 +1,109 @@ +package handlers + +import ( + "testing" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" + + "github.com/Silo-Server/silo-server/internal/plugins" +) + +func TestConfigValuesToJSONRedactsManifestSecrets(t *testing.T) { + manifest := &pluginv1.PluginManifest{GlobalConfigSchema: []*pluginv1.ConfigSchema{{ + Key: "account", + AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{ + {Key: "api_key", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_PASSWORD}, + {Key: "region", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT}, + }}, + }}} + configs := []*plugins.RuntimeConfig{{ + Key: "account", + Value: map[string]any{ + "api_key": "clawrouter-e2e-secret", + "region": "us-east", + }, + }} + + result := configValuesToJSON(configs, manifest) + if len(result) != 1 { + t.Fatalf("result len = %d, want 1", len(result)) + } + if _, leaked := result[0].Value["api_key"]; leaked { + t.Fatalf("redacted response leaked api_key: %#v", result[0]) + } + if result[0].Value["region"] != "us-east" { + t.Fatalf("region = %#v", result[0].Value["region"]) + } + if len(result[0].ConfiguredSecrets) != 1 || result[0].ConfiguredSecrets[0] != "api_key" { + t.Fatalf("configured secrets = %#v", result[0].ConfiguredSecrets) + } +} + +func TestConfigValuesToJSONFailsClosedWithoutManifest(t *testing.T) { + result := configValuesToJSON([]*plugins.RuntimeConfig{{ + Key: "account", + Value: map[string]any{"api_key": "clawrouter-e2e-secret"}, + }}, nil) + if len(result) != 1 || len(result[0].Value) != 0 { + t.Fatalf("result = %#v, want empty value", result) + } +} + +func TestConfigValuesToJSONFailsClosedWithoutMatchingSchema(t *testing.T) { + manifest := &pluginv1.PluginManifest{GlobalConfigSchema: []*pluginv1.ConfigSchema{{ + Key: "replacement", + }}} + result := configValuesToJSON([]*plugins.RuntimeConfig{{ + Key: "retired", + Value: map[string]any{"api_key": "clawrouter-e2e-secret"}, + }}, manifest) + if len(result) != 1 || len(result[0].Value) != 0 { + t.Fatalf("result = %#v, want empty value for retired schema", result) + } +} + +func TestConfigValuesToJSONRedactsObjectsContainingNestedSecrets(t *testing.T) { + for _, tc := range []struct { + name string + jsonSchema string + }{ + { + name: "inline nested schema", + jsonSchema: `{"type":"object","properties":{"connection":{"type":"object","properties":{"api_key":{"type":"string","format":"password"},"endpoint":{"type":"string"}}},"region":{"type":"string"}}}`, + }, + { + name: "local schema reference", + jsonSchema: `{"type":"object","properties":{"connection":{"$ref":"#/$defs/connection"},"region":{"type":"string"}},"$defs":{"connection":{"type":"object","properties":{"api_key":{"type":"string","writeOnly":true}}}}}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + manifest := &pluginv1.PluginManifest{GlobalConfigSchema: []*pluginv1.ConfigSchema{{ + Key: "account", + JsonSchema: tc.jsonSchema, + }}} + result := configValuesToJSON([]*plugins.RuntimeConfig{{ + Key: "account", + Value: map[string]any{ + "connection": map[string]any{ + "api_key": "clawrouter-e2e-secret", + "endpoint": "https://metadata.example.invalid", + }, + "region": "us-east", + }, + }}, manifest) + + if len(result) != 1 { + t.Fatalf("result len = %d, want 1", len(result)) + } + if _, leaked := result[0].Value["connection"]; leaked { + t.Fatalf("response leaked nested secret container: %#v", result[0]) + } + if result[0].Value["region"] != "us-east" { + t.Fatalf("region = %#v, want us-east", result[0].Value["region"]) + } + if len(result[0].ConfiguredSecrets) != 1 || result[0].ConfiguredSecrets[0] != "connection" { + t.Fatalf("configured secrets = %#v, want connection", result[0].ConfiguredSecrets) + } + }) + } +} diff --git a/internal/api/handlers/rate_limits.go b/internal/api/handlers/rate_limits.go index 14f86e72..2b39f522 100644 --- a/internal/api/handlers/rate_limits.go +++ b/internal/api/handlers/rate_limits.go @@ -2,23 +2,32 @@ package handlers import ( "encoding/json" + "fmt" + "maps" + "math" "net/http" + "strings" "github.com/Silo-Server/silo-server/internal/cache" + "github.com/Silo-Server/silo-server/internal/config" "github.com/Silo-Server/silo-server/internal/ratelimit" ) // RateLimitHandler handles rate limit config admin endpoints. type RateLimitHandler struct { - store ratelimit.SettingsStore - mw *ratelimit.Middleware - eventBus cache.EventBus - restartStatus *ServerRestartStatusTracker + store ratelimit.SettingsStore + mw *ratelimit.Middleware + eventBus cache.EventBus + restartStatus *ServerRestartStatusTracker + redisBootstrapAvailable bool } // NewRateLimitHandler creates a new RateLimitHandler. -func NewRateLimitHandler(store ratelimit.SettingsStore, mw *ratelimit.Middleware, eventBus cache.EventBus, restartStatus *ServerRestartStatusTracker) *RateLimitHandler { - return &RateLimitHandler{store: store, mw: mw, eventBus: eventBus, restartStatus: restartStatus} +func NewRateLimitHandler(store ratelimit.SettingsStore, mw *ratelimit.Middleware, eventBus cache.EventBus, restartStatus *ServerRestartStatusTracker, redisBootstrapAvailable ...bool) *RateLimitHandler { + return &RateLimitHandler{ + store: store, mw: mw, eventBus: eventBus, restartStatus: restartStatus, + redisBootstrapAvailable: len(redisBootstrapAvailable) > 0 && redisBootstrapAvailable[0], + } } type rateLimitConfigResponse struct { @@ -50,6 +59,28 @@ type authEndpointConfigResponse struct { Burst int `json:"burst"` } +type rateLimitConfigRequest struct { + Enabled *bool `json:"enabled"` + Backend string `json:"backend"` + GlobalReqPerSecond *float64 `json:"global_requests_per_second"` + Tiers map[string]tierConfigRequest `json:"tiers"` + IPReqPerSecond *float64 `json:"ip_requests_per_second"` + IPReqPerMinute *float64 `json:"ip_requests_per_minute"` + IPBurst *int `json:"ip_burst"` + AuthEndpoints map[string]authEndpointConfigRequest `json:"auth_endpoints"` +} + +type tierConfigRequest struct { + RequestsPerSecond *float64 `json:"requests_per_second"` + RequestsPerMinute *float64 `json:"requests_per_minute"` + Burst *int `json:"burst"` +} + +type authEndpointConfigRequest struct { + RequestsPerMinute *float64 `json:"requests_per_minute"` + Burst *int `json:"burst"` +} + // HandleGetConfig handles GET /admin/rate-limits/config. func (h *RateLimitHandler) HandleGetConfig(w http.ResponseWriter, r *http.Request) { cfg, err := ratelimit.LoadConfig(r.Context(), h.store) @@ -97,77 +128,84 @@ func (h *RateLimitHandler) HandleGetConfig(w http.ResponseWriter, r *http.Reques // HandleUpdateConfig handles PUT /admin/rate-limits/config. func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Request) { - var req rateLimitConfigResponse + var req rateLimitConfigRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } - // Load existing config so we can preserve fields not included in the request. - existing, err := ratelimit.LoadConfig(r.Context(), h.store) - if err != nil { - writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load existing config") + updater, ok := h.store.(serverSettingsAtomicUpdater) + if !ok { + writeError(w, http.StatusInternalServerError, "internal_error", "Settings store does not support atomic updates") return } - cfg := ratelimit.Config{ - Enabled: req.Enabled, - GlobalReqPerSecond: req.GlobalReqPerSecond, - Tiers: make(map[string]ratelimit.TierConfig), - IPReqPerSecond: req.IPReqPerSecond, - IPReqPerMinute: req.IPReqPerMinute, - IPBurst: req.IPBurst, - AuthEndpoints: make(map[string]ratelimit.AuthEndpointConfig), - } - - // Preserve IP settings if not provided (zero values mean omitted from request). - if cfg.IPReqPerSecond == 0 { - cfg.IPReqPerSecond = existing.IPReqPerSecond - } - if cfg.IPReqPerMinute == 0 { - cfg.IPReqPerMinute = existing.IPReqPerMinute - } - if cfg.IPBurst == 0 { - cfg.IPBurst = existing.IPBurst - } - - for name, tier := range req.Tiers { - cfg.Tiers[name] = ratelimit.TierConfig{ - RequestsPerSecond: tier.RequestsPerSecond, - RequestsPerMinute: tier.RequestsPerMinute, - Burst: tier.Burst, + var ( + changed bool + requestErr error + requestError = "invalid_rate_limit_config" + ) + err := updater.UpdateAtomic(r.Context(), func(current map[string]string) (map[string]string, error) { + existing := ratelimit.ConfigFromSettings(current) + merged, err := mergeRateLimitConfig(existing, req) + if err != nil { + requestErr = err + return nil, err } - } - // Preserve existing auth endpoint settings if not provided in request. - for name, ep := range existing.AuthEndpoints { - cfg.AuthEndpoints[name] = ep - } - for name, ep := range req.AuthEndpoints { - cfg.AuthEndpoints[name] = ratelimit.AuthEndpointConfig{ - RequestsPerMinute: ep.RequestsPerMinute, - Burst: ep.Burst, - } - } - - if err := ratelimit.SaveConfig(r.Context(), h.store, cfg); err != nil { - writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save rate limit config") - return - } - - // Save backend setting (infrastructure-level, requires restart) - if req.Backend == "memory" || req.Backend == "redis" { - currentBackend, _ := h.store.Get(r.Context(), "ratelimit.backend") + currentBackend := strings.TrimSpace(strings.ToLower(current["ratelimit.backend"])) if currentBackend == "" { currentBackend = "memory" } - if err := h.store.Set(r.Context(), "ratelimit.backend", req.Backend); err != nil { - writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save backend setting") - return + backend := strings.TrimSpace(strings.ToLower(req.Backend)) + if backend == "" { + backend = currentBackend } - if currentBackend != req.Backend { - h.restartStatus.MarkRequired("ratelimit_backend") + if backend != "memory" && backend != "redis" { + requestErr = fmt.Errorf("backend must be memory or redis") + return nil, requestErr } + if backend == "redis" && !redisConfiguredSettings(current, h.redisBootstrapAvailable) { + requestError = "redis_not_configured" + requestErr = fmt.Errorf("configure a Redis URL, or start the server with a valid Sentinel deployment, before selecting the Redis rate-limit backend") + return nil, requestErr + } + + values := ratelimit.ConfigSettings(merged) + values["ratelimit.backend"] = backend + currentValues := ratelimit.ConfigSettings(existing) + currentValues["ratelimit.backend"] = currentBackend + changed = !maps.Equal(values, currentValues) + if !changed { + return nil, nil + } + return values, nil + }) + if requestErr != nil { + writeError(w, http.StatusBadRequest, requestError, requestErr.Error()) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save rate limit config") + return + } + if !changed { + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "restart_required": false}) + return + } + + // Another process may have committed a newer settings mutation after this + // request released the mutation lock. Base post-commit behavior on a fresh + // snapshot so reordered requests converge on the latest durable state. + latest, err := h.store.GetAll(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Config saved but latest settings could not be loaded") + return + } + latestConfig := ratelimit.ConfigFromSettings(latest) + latestBackend := strings.TrimSpace(strings.ToLower(latest["ratelimit.backend"])) + if latestBackend == "" { + latestBackend = "memory" } // The limiter is constructed at startup, so enabling while it is absent @@ -175,17 +213,21 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req // Everything else hot-reloads below. restartRequired := false if h.mw == nil { - restartRequired = req.Enabled + restartRequired = latestConfig.Enabled } else { - if (req.Backend == "memory" || req.Backend == "redis") && req.Backend != h.mw.ActiveBackend() { + if latestBackend != h.mw.ActiveBackend() { restartRequired = true } - // Hot-reload: apply new config immediately on this instance + // Reload reads the store again rather than applying the request-local + // merge, ensuring the middleware receives the latest committed config. if err := h.mw.Reload(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Config saved but reload failed") return } } + if restartRequired { + h.restartStatus.MarkRequired("ratelimit_backend") + } // Publish for multi-instance reload (if EventBus is available/backed by Redis) if h.eventBus != nil { @@ -197,3 +239,139 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"status": "ok", "restart_required": restartRequired}) } + +func mergeRateLimitConfig(existing ratelimit.Config, req rateLimitConfigRequest) (ratelimit.Config, error) { + cfg := existing + cfg.Tiers = make(map[string]ratelimit.TierConfig, len(existing.Tiers)) + for name, tier := range existing.Tiers { + cfg.Tiers[name] = tier + } + cfg.AuthEndpoints = make(map[string]ratelimit.AuthEndpointConfig, len(existing.AuthEndpoints)) + for name, endpoint := range existing.AuthEndpoints { + cfg.AuthEndpoints[name] = endpoint + } + + if req.Enabled != nil { + cfg.Enabled = *req.Enabled + } + if req.GlobalReqPerSecond != nil { + cfg.GlobalReqPerSecond = *req.GlobalReqPerSecond + } + if req.IPReqPerSecond != nil { + cfg.IPReqPerSecond = *req.IPReqPerSecond + } + if req.IPReqPerMinute != nil { + cfg.IPReqPerMinute = *req.IPReqPerMinute + } + if req.IPBurst != nil { + cfg.IPBurst = *req.IPBurst + } + + for name, update := range req.Tiers { + tier, ok := cfg.Tiers[name] + if !ok { + return ratelimit.Config{}, fmt.Errorf("unknown API-key tier %q", name) + } + if update.RequestsPerSecond != nil { + tier.RequestsPerSecond = *update.RequestsPerSecond + } + if update.RequestsPerMinute != nil { + tier.RequestsPerMinute = *update.RequestsPerMinute + } + if update.Burst != nil { + tier.Burst = *update.Burst + } + cfg.Tiers[name] = tier + } + + for name, update := range req.AuthEndpoints { + endpoint, ok := cfg.AuthEndpoints[name] + if !ok { + return ratelimit.Config{}, fmt.Errorf("unknown auth endpoint %q", name) + } + if update.RequestsPerMinute != nil { + endpoint.RequestsPerMinute = *update.RequestsPerMinute + } + if update.Burst != nil { + endpoint.Burst = *update.Burst + } + cfg.AuthEndpoints[name] = endpoint + } + + if err := validateRateLimitConfig(cfg); err != nil { + return ratelimit.Config{}, err + } + return cfg, nil +} + +func validateRateLimitConfig(cfg ratelimit.Config) error { + if err := boundedRate("global_requests_per_second", cfg.GlobalReqPerSecond, ratelimit.MaxGlobalRequestsPerSecond); err != nil { + return err + } + if err := boundedRate("ip_requests_per_second", cfg.IPReqPerSecond, ratelimit.MaxRequestsPerWindow); err != nil { + return err + } + if err := boundedRate("ip_requests_per_minute", cfg.IPReqPerMinute, ratelimit.MaxRequestsPerWindow); err != nil { + return err + } + if err := boundedBurst("ip_burst", cfg.IPBurst); err != nil { + return err + } + for name, tier := range cfg.Tiers { + if err := boundedRate("tier."+name+".requests_per_second", tier.RequestsPerSecond, ratelimit.MaxRequestsPerWindow); err != nil { + return err + } + if err := boundedRate("tier."+name+".requests_per_minute", tier.RequestsPerMinute, ratelimit.MaxRequestsPerWindow); err != nil { + return err + } + if err := boundedBurst("tier."+name+".burst", tier.Burst); err != nil { + return err + } + } + for name, endpoint := range cfg.AuthEndpoints { + if err := boundedRate("auth."+name+".requests_per_minute", endpoint.RequestsPerMinute, ratelimit.MaxRequestsPerWindow); err != nil { + return err + } + if err := boundedBurst("auth."+name+".burst", endpoint.Burst); err != nil { + return err + } + } + return nil +} + +func boundedRate(name string, value, maxValue float64) error { + if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 || value > maxValue { + return fmt.Errorf("%s must be a finite number greater than zero and no greater than %g", name, maxValue) + } + return nil +} + +func boundedBurst(name string, value int) error { + if value <= 0 || value > ratelimit.MaxBurst { + return fmt.Errorf("%s must be an integer between 1 and %d", name, ratelimit.MaxBurst) + } + return nil +} + +func redisConfiguredSettings(values map[string]string, redisBootstrapAvailable bool) bool { + // Sentinel addresses are bootstrap-only and intentionally have no flat + // server_settings representation (see config.LoadFromDB). A usable Sentinel + // deployment or REDIS_URL override is therefore captured by + // redisBootstrapAvailable from startup config and takes precedence over any + // stale persisted redis.url row. + if redisBootstrapAvailable { + return true + } + + // redis.url is the only Redis transport that can become usable from a + // persisted Admin setting before the next restart. + redisURL := values["redis.url"] + if redisURL == "" { + return false + } + normalized, err := config.NormalizeRedisURL(redisURL) + // The startup loader consumes the persisted value verbatim. Require its + // stored representation to already be canonical so a value accepted here + // cannot fail after restart. + return err == nil && normalized == redisURL +} diff --git a/internal/api/handlers/rate_limits_test.go b/internal/api/handlers/rate_limits_test.go index 3bfa33fd..976fd3f6 100644 --- a/internal/api/handlers/rate_limits_test.go +++ b/internal/api/handlers/rate_limits_test.go @@ -12,7 +12,10 @@ import ( ) type fakeRateLimitStore struct { - values map[string]string + values map[string]string + setCalls int + setManyCalls int + atomicCalls int } func newFakeRateLimitStore() *fakeRateLimitStore { @@ -24,10 +27,19 @@ func (s *fakeRateLimitStore) Get(_ context.Context, key string) (string, error) } func (s *fakeRateLimitStore) Set(_ context.Context, key, value string) error { + s.setCalls++ s.values[key] = value return nil } +func (s *fakeRateLimitStore) SetMany(_ context.Context, values map[string]string) error { + s.setManyCalls++ + for key, value := range values { + s.values[key] = value + } + return nil +} + func (s *fakeRateLimitStore) GetAll(_ context.Context) (map[string]string, error) { out := make(map[string]string, len(s.values)) for k, v := range s.values { @@ -36,6 +48,49 @@ func (s *fakeRateLimitStore) GetAll(_ context.Context) (map[string]string, error return out, nil } +func (s *fakeRateLimitStore) UpdateAtomic( + ctx context.Context, + update func(current map[string]string) (map[string]string, error), +) error { + s.atomicCalls++ + current, err := s.GetAll(ctx) + if err != nil { + return err + } + writes, err := update(current) + if err != nil || len(writes) == 0 { + return err + } + return s.SetMany(ctx, writes) +} + +type postCommitOverwriteRateLimitStore struct { + *fakeRateLimitStore +} + +func (s *postCommitOverwriteRateLimitStore) UpdateAtomic( + ctx context.Context, + update func(current map[string]string) (map[string]string, error), +) error { + s.atomicCalls++ + current, err := s.GetAll(ctx) + if err != nil { + return err + } + writes, err := update(current) + if err != nil || len(writes) == 0 { + return err + } + if err := s.SetMany(ctx, writes); err != nil { + return err + } + + // Model a later request committing before this handler performs its + // post-commit reload and restart decision. + s.values["ratelimit.enabled"] = "false" + return nil +} + func newRunningRateLimitMiddleware(t *testing.T, store ratelimit.SettingsStore) *ratelimit.Middleware { t.Helper() perKey := ratelimit.NewMemoryLimiter() @@ -86,7 +141,8 @@ func TestRateLimitHandlerWithoutRunningLimiter(t *testing.T) { // endpoints must still work so an admin can re-enable it from the UI. store := newFakeRateLimitStore() store.values["ratelimit.enabled"] = "false" - h := NewRateLimitHandler(store, nil, nil, NewServerRestartStatusTracker()) + tracker := NewServerRestartStatusTracker() + h := NewRateLimitHandler(store, nil, nil, tracker, true) got := getRateLimitConfig(t, h) if got.Enabled { @@ -106,6 +162,12 @@ func TestRateLimitHandlerWithoutRunningLimiter(t *testing.T) { if store.values["ratelimit.backend"] != "redis" { t.Errorf("ratelimit.backend = %q, want \"redis\"", store.values["ratelimit.backend"]) } + if store.setManyCalls != 1 { + t.Fatalf("atomic writes = %d, want 1", store.setManyCalls) + } + if !tracker.Snapshot().RestartRequired { + t.Fatal("enabling a stopped limiter did not update server restart status") + } resp = putRateLimitConfig(t, h, `{"enabled":false,"backend":"redis"}`) if resp["restart_required"] != false { @@ -113,10 +175,26 @@ func TestRateLimitHandlerWithoutRunningLimiter(t *testing.T) { } } +func TestRateLimitHandlerUsesLatestCommittedSettingsAfterAtomicWrite(t *testing.T) { + baseStore := newFakeRateLimitStore() + baseStore.values["ratelimit.enabled"] = "false" + store := &postCommitOverwriteRateLimitStore{fakeRateLimitStore: baseStore} + h := NewRateLimitHandler(store, nil, nil, NewServerRestartStatusTracker()) + + resp := putRateLimitConfig(t, h, `{"enabled":true}`) + + if resp["restart_required"] != false { + t.Fatalf("restart_required = %v, want false from latest committed disabled state", resp["restart_required"]) + } + if got := store.values["ratelimit.enabled"]; got != "false" { + t.Fatalf("ratelimit.enabled = %q, want later committed value false", got) + } +} + func TestRateLimitHandlerWithRunningLimiter(t *testing.T) { store := newFakeRateLimitStore() mw := newRunningRateLimitMiddleware(t, store) - h := NewRateLimitHandler(store, mw, nil, NewServerRestartStatusTracker()) + h := NewRateLimitHandler(store, mw, nil, NewServerRestartStatusTracker(), true) got := getRateLimitConfig(t, h) if !got.Active { @@ -138,3 +216,140 @@ func TestRateLimitHandlerWithRunningLimiter(t *testing.T) { t.Errorf("backend change: restart_required = %v, want true", resp["restart_required"]) } } + +func TestRateLimitHandlerRejectsExplicitZeroWithoutWriting(t *testing.T) { + store := newFakeRateLimitStore() + h := NewRateLimitHandler(store, nil, nil, NewServerRestartStatusTracker()) + + req := httptest.NewRequest(http.MethodPut, "/admin/rate-limits/config", strings.NewReader( + `{"tiers":{"standard":{"requests_per_second":0}}}`, + )) + rec := httptest.NewRecorder() + h.HandleUpdateConfig(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("PUT status = %d, want 400; body = %s", rec.Code, rec.Body.String()) + } + if store.setManyCalls != 0 || store.setCalls != 0 { + t.Fatalf("invalid request wrote settings: SetMany=%d Set=%d", store.setManyCalls, store.setCalls) + } +} + +func TestRateLimitHandlerRejectsOverflowingGlobalRateWithoutWriting(t *testing.T) { + store := newFakeRateLimitStore() + h := NewRateLimitHandler(store, nil, nil, NewServerRestartStatusTracker()) + + req := httptest.NewRequest(http.MethodPut, "/admin/rate-limits/config", strings.NewReader( + `{"global_requests_per_second":1e308}`, + )) + rec := httptest.NewRecorder() + h.HandleUpdateConfig(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("PUT status = %d, want 400; body = %s", rec.Code, rec.Body.String()) + } + if store.setManyCalls != 0 || store.setCalls != 0 { + t.Fatalf("invalid request wrote settings: SetMany=%d Set=%d", store.setManyCalls, store.setCalls) + } +} + +func TestRateLimitHandlerRejectsOverflowingBurstWithoutWriting(t *testing.T) { + store := newFakeRateLimitStore() + h := NewRateLimitHandler(store, nil, nil, NewServerRestartStatusTracker()) + + req := httptest.NewRequest(http.MethodPut, "/admin/rate-limits/config", strings.NewReader( + `{"ip_burst":9223372036854775807}`, + )) + rec := httptest.NewRecorder() + h.HandleUpdateConfig(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("PUT status = %d, want 400; body = %s", rec.Code, rec.Body.String()) + } + if store.setManyCalls != 0 || store.setCalls != 0 { + t.Fatalf("invalid request wrote settings: SetMany=%d Set=%d", store.setManyCalls, store.setCalls) + } +} + +func TestRateLimitHandlerRejectsRedisWithoutRedisConfiguration(t *testing.T) { + store := newFakeRateLimitStore() + h := NewRateLimitHandler(store, nil, nil, NewServerRestartStatusTracker()) + + req := httptest.NewRequest(http.MethodPut, "/admin/rate-limits/config", strings.NewReader( + `{"backend":"redis"}`, + )) + rec := httptest.NewRecorder() + h.HandleUpdateConfig(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("PUT status = %d, want 400; body = %s", rec.Code, rec.Body.String()) + } + if store.setManyCalls != 0 { + t.Fatalf("invalid Redis selection wrote settings: SetMany=%d", store.setManyCalls) + } +} + +func TestRateLimitHandlerRejectsMalformedPersistedRedisURL(t *testing.T) { + store := newFakeRateLimitStore() + store.values["redis.url"] = "not-a-url" + h := NewRateLimitHandler(store, nil, nil, NewServerRestartStatusTracker()) + + req := httptest.NewRequest(http.MethodPut, "/admin/rate-limits/config", strings.NewReader( + `{"backend":"redis"}`, + )) + rec := httptest.NewRecorder() + h.HandleUpdateConfig(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("PUT status = %d, want 400; body = %s", rec.Code, rec.Body.String()) + } + if store.setManyCalls != 0 { + t.Fatalf("invalid Redis selection wrote settings: SetMany=%d", store.setManyCalls) + } +} + +func TestRateLimitHandlerAcceptsBootstrapRedisDespiteStalePersistedURL(t *testing.T) { + store := newFakeRateLimitStore() + store.values["redis.url"] = " redis://cache.example.invalid:6379 " + h := NewRateLimitHandler(store, nil, nil, NewServerRestartStatusTracker(), true) + + req := httptest.NewRequest(http.MethodPut, "/admin/rate-limits/config", strings.NewReader( + `{"backend":"redis"}`, + )) + rec := httptest.NewRecorder() + h.HandleUpdateConfig(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("PUT status = %d, want 200; body = %s", rec.Code, rec.Body.String()) + } + if store.values["ratelimit.backend"] != "redis" { + t.Fatalf("ratelimit.backend = %q, want redis", store.values["ratelimit.backend"]) + } +} + +func TestRateLimitHandlerDoesNotTreatActiveRedisAsDurableConfiguration(t *testing.T) { + store := newFakeRateLimitStore() + perKey := ratelimit.NewMemoryLimiter() + global := ratelimit.NewMemoryLimiter() + t.Cleanup(func() { + perKey.Close() + global.Close() + }) + // isMemory=false models a process currently using Redis. With no stored or + // bootstrap transport, that active state cannot survive the next restart. + mw := ratelimit.NewMiddleware(perKey, global, store, false) + h := NewRateLimitHandler(store, mw, nil, NewServerRestartStatusTracker()) + + req := httptest.NewRequest(http.MethodPut, "/admin/rate-limits/config", strings.NewReader( + `{"backend":"redis"}`, + )) + rec := httptest.NewRecorder() + h.HandleUpdateConfig(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("PUT status = %d, want 400; body = %s", rec.Code, rec.Body.String()) + } + if store.setManyCalls != 0 { + t.Fatalf("invalid Redis selection wrote settings: SetMany=%d", store.setManyCalls) + } +} diff --git a/internal/api/handlers/settings.go b/internal/api/handlers/settings.go index 4930493b..1a430fa4 100644 --- a/internal/api/handlers/settings.go +++ b/internal/api/handlers/settings.go @@ -27,6 +27,10 @@ import ( const deviceSeenThrottle = 5 * time.Minute const subtitleAppearanceSettingKey = "subtitle_appearance" +const ( + legacyAndroidNextUpPromptSettingKey = "player.next_up_prompt_seconds" + canonicalNextUpPromptSettingKey = "playback.next_up_prompt_seconds" +) const ( libraryPageStateSettingKey = "ui.library_page_state" rememberLibraryPageStateSettingKey = "ui.remember_library_page_state" @@ -185,10 +189,10 @@ var settingsRegistry = map[string]settingSpec{ DefaultValue: "true", Validate: validateBoolSetting("playback.auto_play_next"), }, - "playback.next_up_prompt_seconds": { + canonicalNextUpPromptSettingKey: { Scope: scopeDevice, DefaultValue: "30", - Validate: validateIntRange("playback.next_up_prompt_seconds", 0, 120), + Validate: validateIntRange(canonicalNextUpPromptSettingKey, 0, 120), }, subtitleAppearanceSettingKey: { Scope: scopeDevice, @@ -424,6 +428,8 @@ func (h *SettingsHandler) HandleGetDeviceSetting(w http.ResponseWriter, r *http. writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") return } + requestedKey := key + key = canonicalDeviceSettingKey(key) if !keyUsesDeviceScope(key) { writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeDevice)) return @@ -440,7 +446,7 @@ func (h *SettingsHandler) HandleGetDeviceSetting(w http.ResponseWriter, r *http. } h.registerRequestDevice(r.Context(), store, profileID, device) - value, err := store.GetDeviceSetting(r.Context(), profileID, device.DeviceID, key) + value, err := getDeviceSettingWithLegacyFallback(r.Context(), store, profileID, device.DeviceID, key) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get device setting") return @@ -451,7 +457,7 @@ func (h *SettingsHandler) HandleGetDeviceSetting(w http.ResponseWriter, r *http. } writeJSON(w, http.StatusOK, settingResponse{ - Key: key, + Key: requestedKey, Value: value.Value, }) } @@ -470,6 +476,7 @@ func (h *SettingsHandler) HandleSetDeviceSetting(w http.ResponseWriter, r *http. writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") return } + key = canonicalDeviceSettingKey(key) if !keyUsesDeviceScope(key) { writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeDevice)) return @@ -506,6 +513,15 @@ func (h *SettingsHandler) HandleSetDeviceSetting(w http.ResponseWriter, r *http. writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set device setting") return } + if legacyKey, ok := legacyDeviceSettingKey(key); ok { + if err := store.DeleteDeviceSetting(r.Context(), profileID, device.DeviceID, legacyKey); err != nil { + slog.WarnContext(r.Context(), "failed to clean up legacy device setting after canonical write", + "legacy_key", legacyKey, + "canonical_key", key, + "error", err, + ) + } + } w.WriteHeader(http.StatusNoContent) } @@ -524,6 +540,7 @@ func (h *SettingsHandler) HandleDeleteDeviceSetting(w http.ResponseWriter, r *ht writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") return } + key = canonicalDeviceSettingKey(key) if !keyUsesDeviceScope(key) { writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeDevice)) return @@ -540,6 +557,12 @@ func (h *SettingsHandler) HandleDeleteDeviceSetting(w http.ResponseWriter, r *ht } h.registerRequestDevice(r.Context(), store, profileID, device) + if legacyKey, ok := legacyDeviceSettingKey(key); ok { + if err := store.DeleteDeviceSetting(r.Context(), profileID, device.DeviceID, legacyKey); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting") + return + } + } if err := store.DeleteDeviceSetting(r.Context(), profileID, device.DeviceID, key); err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting") return @@ -721,6 +744,7 @@ func parseSettingKeys(raw string) []string { } func validateRegisteredSetting(key, value string, expectedScope settingsScope) error { + key = canonicalDeviceSettingKey(key) spec, ok := settingsRegistry[key] if !ok { return nil @@ -735,29 +759,69 @@ func validateRegisteredSetting(key, value string, expectedScope settingsScope) e } func keyUsesUserScope(key string) bool { + key = canonicalDeviceSettingKey(key) spec, ok := settingsRegistry[key] return !ok || spec.Scope == scopeUser } func keyUsesDeviceScope(key string) bool { + key = canonicalDeviceSettingKey(key) spec, ok := settingsRegistry[key] return ok && spec.Scope == scopeDevice } func isMigratedPlaybackSetting(key string) bool { + key = canonicalDeviceSettingKey(key) switch key { case "playback.preferred_quality", "playback.audio_language", "playback.auto_skip_intro", "playback.auto_skip_credits", "playback.auto_play_next", - "playback.next_up_prompt_seconds": + canonicalNextUpPromptSettingKey: return true default: return false } } +// canonicalDeviceSettingKey keeps the Android key shipped before the server's +// canonical playback namespace was finalized working without storing two +// independent values. New clients should use playback.next_up_prompt_seconds. +func canonicalDeviceSettingKey(key string) string { + if key == legacyAndroidNextUpPromptSettingKey { + return canonicalNextUpPromptSettingKey + } + return key +} + +func legacyDeviceSettingKey(key string) (string, bool) { + if canonicalDeviceSettingKey(key) == canonicalNextUpPromptSettingKey { + return legacyAndroidNextUpPromptSettingKey, true + } + return "", false +} + +// getDeviceSettingWithLegacyFallback is a read-only canonical-first lookup for +// the Android key used before the playback namespace was finalized. Canonical +// PUT and DELETE requests own migration and cleanup. +func getDeviceSettingWithLegacyFallback( + ctx context.Context, + store userstore.UserStore, + profileID, deviceID, key string, +) (*userstore.DeviceSettingEntry, error) { + override, err := store.GetDeviceSetting(ctx, profileID, deviceID, key) + if err != nil || override != nil { + return override, err + } + + legacyKey, ok := legacyDeviceSettingKey(key) + if !ok { + return nil, nil + } + return store.GetDeviceSetting(ctx, profileID, deviceID, legacyKey) +} + func usesLegacyUserFallback(key string) bool { return key == subtitleAppearanceSettingKey } @@ -827,9 +891,11 @@ func (h *SettingsHandler) resolveEffectiveSetting( device requestDeviceMetadata, key string, ) (effectiveSettingResponse, error) { + requestedKey := key + key = canonicalDeviceSettingKey(key) spec, hasSpec := settingsRegistry[key] resolved := effectiveSettingResponse{ - Key: key, + Key: requestedKey, ProfileID: profileID, DeviceID: device.DeviceID, DeviceName: device.DeviceName, @@ -845,7 +911,7 @@ func (h *SettingsHandler) resolveEffectiveSetting( resolved.Source = "unset" } if device.DeviceID != "" { - override, err := store.GetDeviceSetting(ctx, profileID, device.DeviceID, key) + override, err := getDeviceSettingWithLegacyFallback(ctx, store, profileID, device.DeviceID, key) if err != nil { return effectiveSettingResponse{}, err } diff --git a/internal/api/handlers/settings_device_test.go b/internal/api/handlers/settings_device_test.go index 25542888..f2846697 100644 --- a/internal/api/handlers/settings_device_test.go +++ b/internal/api/handlers/settings_device_test.go @@ -5,6 +5,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -66,6 +67,18 @@ func (p mappedTestUserStoreProvider) ForUser(_ context.Context, userID int) (use func (p mappedTestUserStoreProvider) Close() error { return nil } +type legacyAliasFailureStore struct { + userstore.UserStore + failLegacyDelete bool +} + +func (s legacyAliasFailureStore) DeleteDeviceSetting(ctx context.Context, profileID, deviceID, key string) error { + if s.failLegacyDelete && key == legacyAndroidNextUpPromptSettingKey { + return errors.New("legacy delete failed") + } + return s.UserStore.DeleteDeviceSetting(ctx, profileID, deviceID, key) +} + func newIsolatedProfileTestStore(t *testing.T, suffix string) userstore.UserStore { t.Helper() @@ -222,6 +235,319 @@ func TestGetEffectiveSettingsResolvesUserDeviceAndDefaultSources(t *testing.T) { } } +func TestAndroidNextUpSettingAliasUsesCanonicalStoredValue(t *testing.T) { + store := newProfileTestStore(t) + handler := NewSettingsHandler(testUserStoreProvider{store: store}) + + req := httptest.NewRequest( + http.MethodPut, + "/settings/device/"+legacyAndroidNextUpPromptSettingKey, + bytes.NewBufferString(`{"value":"60"}`), + ) + req = withRouteParams(req, map[string]string{"key": legacyAndroidNextUpPromptSettingKey}) + req.Header.Set(deviceIDHeader, "android-tv") + req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1")) + rec := httptest.NewRecorder() + + handler.HandleSetDeviceSetting(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + canonical, err := store.GetDeviceSetting( + context.Background(), "profile-1", "android-tv", "playback.next_up_prompt_seconds", + ) + if err != nil { + t.Fatalf("GetDeviceSetting: %v", err) + } + if canonical == nil || canonical.Value != "60" { + t.Fatalf("canonical setting = %#v, want value 60", canonical) + } + legacy, err := store.GetDeviceSetting( + context.Background(), "profile-1", "android-tv", legacyAndroidNextUpPromptSettingKey, + ) + if err != nil { + t.Fatalf("GetDeviceSetting legacy: %v", err) + } + if legacy != nil { + t.Fatalf("legacy duplicate setting = %#v, want nil", legacy) + } + + resolved, err := handler.resolveEffectiveSetting( + context.Background(), + store, + "profile-1", + requestDeviceMetadata{DeviceID: "android-tv"}, + legacyAndroidNextUpPromptSettingKey, + ) + if err != nil { + t.Fatalf("resolveEffectiveSetting: %v", err) + } + if resolved.Key != legacyAndroidNextUpPromptSettingKey || resolved.EffectiveValue != "60" { + t.Fatalf("resolved alias = %#v", resolved) + } +} + +func TestAndroidNextUpSettingAliasReadsLegacyDeviceRowWithoutMigration(t *testing.T) { + store := newProfileTestStore(t) + if err := store.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{ + ProfileID: "profile-1", + DeviceID: "android-tv", + Key: legacyAndroidNextUpPromptSettingKey, + Value: "45", + }); err != nil { + t.Fatalf("SetDeviceSetting legacy: %v", err) + } + handler := NewSettingsHandler(testUserStoreProvider{store: store}) + req := httptest.NewRequest( + http.MethodGet, + "/settings/device/"+legacyAndroidNextUpPromptSettingKey, + nil, + ) + req = withRouteParams(req, map[string]string{"key": legacyAndroidNextUpPromptSettingKey}) + req.Header.Set(deviceIDHeader, "android-tv") + req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1")) + rec := httptest.NewRecorder() + + handler.HandleGetDeviceSetting(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var response settingResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("decode: %v", err) + } + if response.Key != legacyAndroidNextUpPromptSettingKey || response.Value != "45" { + t.Fatalf("response = %#v", response) + } + canonical, err := store.GetDeviceSetting( + context.Background(), "profile-1", "android-tv", canonicalNextUpPromptSettingKey, + ) + if err != nil { + t.Fatalf("GetDeviceSetting canonical: %v", err) + } + if canonical != nil { + t.Fatalf("canonical setting = %#v, want nil after read-only GET", canonical) + } + legacy, err := store.GetDeviceSetting( + context.Background(), "profile-1", "android-tv", legacyAndroidNextUpPromptSettingKey, + ) + if err != nil { + t.Fatalf("GetDeviceSetting legacy: %v", err) + } + if legacy == nil || legacy.Value != "45" { + t.Fatalf("legacy setting = %#v, want unchanged value 45", legacy) + } +} + +func TestAndroidNextUpSettingCanonicalGetEchoesCanonicalKey(t *testing.T) { + store := newProfileTestStore(t) + if err := store.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{ + ProfileID: "profile-1", + DeviceID: "android-tv", + Key: canonicalNextUpPromptSettingKey, + Value: "60", + }); err != nil { + t.Fatalf("SetDeviceSetting canonical: %v", err) + } + handler := NewSettingsHandler(testUserStoreProvider{store: store}) + req := httptest.NewRequest( + http.MethodGet, + "/settings/device/"+canonicalNextUpPromptSettingKey, + nil, + ) + req = withRouteParams(req, map[string]string{"key": canonicalNextUpPromptSettingKey}) + req.Header.Set(deviceIDHeader, "android-tv") + req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1")) + rec := httptest.NewRecorder() + + handler.HandleGetDeviceSetting(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var response settingResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("decode: %v", err) + } + if response.Key != canonicalNextUpPromptSettingKey || response.Value != "60" { + t.Fatalf("response = %#v", response) + } +} + +func TestAndroidNextUpSettingAliasPrefersCanonicalDeviceRow(t *testing.T) { + store := newProfileTestStore(t) + for key, value := range map[string]string{ + legacyAndroidNextUpPromptSettingKey: "45", + canonicalNextUpPromptSettingKey: "60", + } { + if err := store.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{ + ProfileID: "profile-1", + DeviceID: "android-tv", + Key: key, + Value: value, + }); err != nil { + t.Fatalf("SetDeviceSetting %s: %v", key, err) + } + } + handler := NewSettingsHandler(testUserStoreProvider{store: store}) + + resolved, err := handler.resolveEffectiveSetting( + context.Background(), + store, + "profile-1", + requestDeviceMetadata{DeviceID: "android-tv"}, + legacyAndroidNextUpPromptSettingKey, + ) + if err != nil { + t.Fatalf("resolveEffectiveSetting: %v", err) + } + if resolved.EffectiveValue != "60" || resolved.Source != "device" { + t.Fatalf("resolved = %#v, want canonical device value 60", resolved) + } +} + +func TestAndroidNextUpSettingDeleteRemovesCanonicalAndLegacyRows(t *testing.T) { + store := newProfileTestStore(t) + for key, value := range map[string]string{ + legacyAndroidNextUpPromptSettingKey: "45", + canonicalNextUpPromptSettingKey: "60", + } { + if err := store.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{ + ProfileID: "profile-1", + DeviceID: "android-tv", + Key: key, + Value: value, + }); err != nil { + t.Fatalf("SetDeviceSetting %s: %v", key, err) + } + } + handler := NewSettingsHandler(testUserStoreProvider{store: store}) + req := httptest.NewRequest( + http.MethodDelete, + "/settings/device/"+legacyAndroidNextUpPromptSettingKey, + nil, + ) + req = withRouteParams(req, map[string]string{"key": legacyAndroidNextUpPromptSettingKey}) + req.Header.Set(deviceIDHeader, "android-tv") + req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1")) + rec := httptest.NewRecorder() + + handler.HandleDeleteDeviceSetting(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + for _, key := range []string{canonicalNextUpPromptSettingKey, legacyAndroidNextUpPromptSettingKey} { + value, err := store.GetDeviceSetting(context.Background(), "profile-1", "android-tv", key) + if err != nil { + t.Fatalf("GetDeviceSetting %s: %v", key, err) + } + if value != nil { + t.Fatalf("setting %s = %#v, want nil", key, value) + } + } +} + +func TestAndroidNextUpSettingAliasDoesNotUseOrDeleteUserRow(t *testing.T) { + store := newProfileTestStore(t) + if err := store.SetSetting(context.Background(), legacyAndroidNextUpPromptSettingKey, "50"); err != nil { + t.Fatalf("SetSetting legacy: %v", err) + } + handler := NewSettingsHandler(testUserStoreProvider{store: store}) + + resolved, err := handler.resolveEffectiveSetting( + context.Background(), + store, + "profile-1", + requestDeviceMetadata{DeviceID: "android-tv"}, + legacyAndroidNextUpPromptSettingKey, + ) + if err != nil { + t.Fatalf("resolveEffectiveSetting: %v", err) + } + if resolved.EffectiveValue != "30" || resolved.Source != "default" { + t.Fatalf("resolved = %#v, want default value 30", resolved) + } + + req := httptest.NewRequest( + http.MethodDelete, + "/settings/device/"+legacyAndroidNextUpPromptSettingKey, + nil, + ) + req = withRouteParams(req, map[string]string{"key": legacyAndroidNextUpPromptSettingKey}) + req.Header.Set(deviceIDHeader, "android-tv") + req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1")) + rec := httptest.NewRecorder() + + handler.HandleDeleteDeviceSetting(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + legacyUserValue, err := store.GetSetting(context.Background(), legacyAndroidNextUpPromptSettingKey) + if err != nil { + t.Fatalf("GetSetting legacy: %v", err) + } + if legacyUserValue != "50" { + t.Fatalf("legacy user setting = %q, want unchanged value 50", legacyUserValue) + } +} + +func TestAndroidNextUpSettingAliasCleanupFailures(t *testing.T) { + t.Run("PUT succeeds when legacy cleanup fails", func(t *testing.T) { + baseStore := newProfileTestStore(t) + store := legacyAliasFailureStore{UserStore: baseStore, failLegacyDelete: true} + handler := NewSettingsHandler(testUserStoreProvider{store: store}) + req := httptest.NewRequest( + http.MethodPut, + "/settings/device/"+legacyAndroidNextUpPromptSettingKey, + bytes.NewBufferString(`{"value":"60"}`), + ) + req = withRouteParams(req, map[string]string{"key": legacyAndroidNextUpPromptSettingKey}) + req.Header.Set(deviceIDHeader, "android-tv") + req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1")) + rec := httptest.NewRecorder() + + handler.HandleSetDeviceSetting(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + canonical, err := baseStore.GetDeviceSetting( + context.Background(), "profile-1", "android-tv", canonicalNextUpPromptSettingKey, + ) + if err != nil { + t.Fatalf("GetDeviceSetting canonical: %v", err) + } + if canonical == nil || canonical.Value != "60" { + t.Fatalf("canonical setting = %#v, want value 60", canonical) + } + }) + + t.Run("DELETE reports legacy cleanup failure", func(t *testing.T) { + baseStore := newProfileTestStore(t) + store := legacyAliasFailureStore{UserStore: baseStore, failLegacyDelete: true} + handler := NewSettingsHandler(testUserStoreProvider{store: store}) + req := httptest.NewRequest( + http.MethodDelete, + "/settings/device/"+legacyAndroidNextUpPromptSettingKey, + nil, + ) + req = withRouteParams(req, map[string]string{"key": legacyAndroidNextUpPromptSettingKey}) + req.Header.Set(deviceIDHeader, "android-tv") + req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1")) + rec := httptest.NewRecorder() + + handler.HandleDeleteDeviceSetting(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500; body=%s", rec.Code, rec.Body.String()) + } + }) +} + func TestGenericSettingsRejectInvalidRegisteredValues(t *testing.T) { store := newProfileTestStore(t) handler := NewSettingsHandler(testUserStoreProvider{store: store}) diff --git a/internal/api/handlers/theme.go b/internal/api/handlers/theme.go index 9f6bf7bd..4865edb3 100644 --- a/internal/api/handlers/theme.go +++ b/internal/api/handlers/theme.go @@ -8,6 +8,8 @@ import ( "net/url" "sync" "time" + + "github.com/Silo-Server/silo-server/internal/config" ) // ThemeSettingsReader is the subset of ServerSettingsStore needed by ThemeHandler. @@ -23,14 +25,20 @@ type ThemeHandler struct { catalogMu sync.RWMutex catalogCache []byte catalogFetched time.Time + catalogURL string httpClient *http.Client } // NewThemeHandler creates a ThemeHandler. func NewThemeHandler(settings ThemeSettingsReader) *ThemeHandler { return &ThemeHandler{ - settings: settings, - httpClient: &http.Client{Timeout: 10 * time.Second}, + settings: settings, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(req *http.Request, _ []*http.Request) error { + return config.ValidateThemeRemoteURL(req.URL) + }, + }, } } @@ -47,20 +55,13 @@ func (h *ThemeHandler) HandleAdminCSS(w http.ResponseWriter, r *http.Request) { vars, _ := h.settings.Get(r.Context(), "ui.admin_theme_vars") rawCSS, _ := h.settings.Get(r.Context(), "ui.admin_custom_css") - w.Header().Set("Cache-Control", "public, max-age=60") + w.Header().Set("Cache-Control", "no-store") writeJSON(w, http.StatusOK, adminCssResponse{ Vars: vars, RawCSS: rawCSS, }) } -// allowedDownloadHosts restricts which hosts the theme download proxy will fetch from. -var allowedDownloadHosts = map[string]bool{ - "raw.githubusercontent.com": true, - "github.com": true, - "objects.githubusercontent.com": true, -} - // HandleDownload proxies a theme file download from an allowed host. // This prevents the browser from directly fetching arbitrary URLs (SSRF). func (h *ThemeHandler) HandleDownload(w http.ResponseWriter, r *http.Request) { @@ -75,9 +76,8 @@ func (h *ThemeHandler) HandleDownload(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "bad_request", "Invalid URL") return } - - if !allowedDownloadHosts[parsed.Hostname()] { - writeError(w, http.StatusForbidden, "host_not_allowed", "Theme downloads are only allowed from approved hosts") + if config.ValidateThemeRemoteURL(parsed) != nil { + writeError(w, http.StatusForbidden, "host_not_allowed", "Theme downloads are only allowed over HTTPS from approved hosts") return } @@ -117,8 +117,7 @@ func (h *ThemeHandler) HandleDownload(w http.ResponseWriter, r *http.Request) { } const ( - defaultCatalogURL = "https://raw.githubusercontent.com/Silo-Server/silo-themes/main/catalog.json" - catalogCacheTTL = 1 * time.Hour + catalogCacheTTL = 1 * time.Hour ) // writeStaleCatalog serves a cached catalog response with the stale header. @@ -131,11 +130,26 @@ func writeStaleCatalog(w http.ResponseWriter, cached []byte) { // HandleCatalog proxies the theme catalog from a remote URL with caching. func (h *ThemeHandler) HandleCatalog(w http.ResponseWriter, r *http.Request) { - // Check cache first. + // Read and validate the configured origin before consulting the cache. A + // cache entry belongs to one URL and must never mask a saved URL change. + catalogURL, _ := h.settings.Get(r.Context(), "theme.catalog_url") + if catalogURL == "" { + catalogURL = config.DefaultThemeCatalogURL + } + parsedCatalogURL, err := url.Parse(catalogURL) + if err != nil || config.ValidateThemeRemoteURL(parsedCatalogURL) != nil { + writeError(w, http.StatusBadRequest, "catalog_url_invalid", "Theme catalog URL must use HTTPS on an approved GitHub host") + return + } + h.catalogMu.RLock() cached := h.catalogCache age := time.Since(h.catalogFetched) + cachedURL := h.catalogURL h.catalogMu.RUnlock() + if cachedURL != catalogURL { + cached = nil + } if cached != nil && age < catalogCacheTTL { w.Header().Set("Content-Type", "application/json") @@ -144,12 +158,6 @@ func (h *ThemeHandler) HandleCatalog(w http.ResponseWriter, r *http.Request) { return } - // Determine catalog URL from server settings. - catalogURL, _ := h.settings.Get(r.Context(), "theme.catalog_url") - if catalogURL == "" { - catalogURL = defaultCatalogURL - } - // Fetch from upstream. req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, catalogURL, nil) if err != nil { @@ -198,6 +206,7 @@ func (h *ThemeHandler) HandleCatalog(w http.ResponseWriter, r *http.Request) { h.catalogMu.Lock() h.catalogCache = body h.catalogFetched = time.Now() + h.catalogURL = catalogURL h.catalogMu.Unlock() w.Header().Set("Content-Type", "application/json") @@ -211,6 +220,7 @@ func (h *ThemeHandler) HandleCatalogRefresh(w http.ResponseWriter, r *http.Reque h.catalogMu.Lock() h.catalogCache = nil h.catalogFetched = time.Time{} + h.catalogURL = "" h.catalogMu.Unlock() // Immediately fetch fresh data so the caller gets the updated catalog. diff --git a/internal/api/handlers/theme_test.go b/internal/api/handlers/theme_test.go new file mode 100644 index 00000000..ca05251b --- /dev/null +++ b/internal/api/handlers/theme_test.go @@ -0,0 +1,103 @@ +package handlers + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type themeSettingsStub struct { + values map[string]string +} + +func (s *themeSettingsStub) Get(_ context.Context, key string) (string, error) { + return s.values[key], nil +} + +type themeRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f themeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestThemeAdminCSSIsNotBrowserCached(t *testing.T) { + h := NewThemeHandler(&themeSettingsStub{values: map[string]string{ + "ui.admin_theme_vars": `{"--primary":"red"}`, + "ui.admin_custom_css": `.shell { color: red; }`, + }}) + rec := httptest.NewRecorder() + + h.HandleAdminCSS(rec, httptest.NewRequest(http.MethodGet, "/theme/admin-css", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", got) + } +} + +func TestThemeRemoteURLsRequireHTTPSApprovedHost(t *testing.T) { + h := NewThemeHandler(&themeSettingsStub{values: map[string]string{ + "theme.catalog_url": "http://raw.githubusercontent.com/Silo-Server/silo-themes/main/catalog.json", + }}) + + catalog := httptest.NewRecorder() + h.HandleCatalog(catalog, httptest.NewRequest(http.MethodGet, "/theme/catalog", nil)) + if catalog.Code != http.StatusBadRequest { + t.Fatalf("HTTP catalog status = %d, want 400; body=%s", catalog.Code, catalog.Body.String()) + } + + download := httptest.NewRecorder() + h.HandleDownload(download, httptest.NewRequest( + http.MethodGet, + "/theme/download?url=http%3A%2F%2Fraw.githubusercontent.com%2Ftheme.json", + nil, + )) + if download.Code != http.StatusForbidden { + t.Fatalf("HTTP download status = %d, want 403; body=%s", download.Code, download.Body.String()) + } +} + +func TestThemeCatalogCacheIsScopedToConfiguredURL(t *testing.T) { + settings := &themeSettingsStub{values: map[string]string{ + "theme.catalog_url": "https://raw.githubusercontent.com/example/themes/main/one.json", + }} + h := NewThemeHandler(settings) + requests := 0 + h.httpClient = &http.Client{Transport: themeRoundTripFunc(func(req *http.Request) (*http.Response, error) { + requests++ + body := `{"catalog":"one"}` + if strings.HasSuffix(req.URL.Path, "/two.json") { + body = `{"catalog":"two"}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + })} + + first := httptest.NewRecorder() + h.HandleCatalog(first, httptest.NewRequest(http.MethodGet, "/theme/catalog", nil)) + if first.Code != http.StatusOK || !strings.Contains(first.Body.String(), `"one"`) { + t.Fatalf("first response = %d %s", first.Code, first.Body.String()) + } + + // Make the old cache look fresh, then change the saved origin. URL identity + // must still force a new fetch instead of serving the previous catalog. + h.catalogFetched = time.Now() + settings.values["theme.catalog_url"] = "https://raw.githubusercontent.com/example/themes/main/two.json" + second := httptest.NewRecorder() + h.HandleCatalog(second, httptest.NewRequest(http.MethodGet, "/theme/catalog", nil)) + if second.Code != http.StatusOK || !strings.Contains(second.Body.String(), `"two"`) { + t.Fatalf("second response = %d %s", second.Code, second.Body.String()) + } + if requests != 2 { + t.Fatalf("upstream requests = %d, want 2", requests) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 7165bec4..9b8d26a0 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -88,6 +88,7 @@ type Dependencies struct { OnConfigChange func(fn func(old, updated *config.Config)) BootstrapSensitiveConfigured map[string]bool BootstrapSensitiveValues map[string]string + RedisBootstrapAvailable bool AppContext context.Context DB *pgxpool.Pool SecretCipher *secret.Cipher // at-rest credential cipher (required when DB is set) @@ -998,6 +999,7 @@ func NewRouter(deps Dependencies) chi.Router { adminHandler.AccessGroups = accessGroupStore adminHandler.BootstrapSensitiveConfigured = deps.BootstrapSensitiveConfigured adminHandler.BootstrapSensitiveValues = deps.BootstrapSensitiveValues + adminHandler.RedisBootstrapAvailable = deps.RedisBootstrapAvailable adminHandler.RestartStatus = restartStatus adminHandler.CatalogSearchStatus = catalogSearchService adminHandler.DiagnosticsStore = diagnosticsStore @@ -2188,7 +2190,7 @@ func NewRouter(deps Dependencies) chi.Router { pluginHandler := handlers.NewPluginHandler( plugins.NewRepositoryStore(deps.DB), plugins.NewInstallationStore(deps.DB), - plugins.NewRuntimeConfigStore(deps.DB), + plugins.NewRuntimeConfigStore(deps.DB, deps.SecretCipher), deps.PluginService, deps.PluginUserConfig, deps.PluginHTTPProxy, @@ -2600,8 +2602,10 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/settings/sections", sectionSettingsHandler.HandleGet) r.Put("/settings/sections", sectionSettingsHandler.HandlePut) } + r.Get("/settings/effective", adminHandler.HandleGetEffectiveSettings) r.Get("/settings/{key}", adminHandler.HandleGetSetting) r.Get("/settings", adminHandler.HandleGetSettings) + r.Put("/settings", adminHandler.HandleUpdateSettings) r.Put("/settings/{key}", adminHandler.HandleUpdateSetting) if brandingHandler != nil { // Branding image upload/delete (scalar branding @@ -2624,6 +2628,7 @@ func NewRouter(deps Dependencies) chi.Router { } if settingsRepo != nil { r.Post("/notifications/push/relay/register", applePushHandler.HandleRegisterRelay) + r.Delete("/notifications/push/relay", applePushHandler.HandleClearRelay) } } if deps.Notifications != nil && deps.Notifications.ServerChannels != nil { @@ -2692,7 +2697,7 @@ func NewRouter(deps Dependencies) chi.Router { pluginHandler := handlers.NewPluginHandler( plugins.NewRepositoryStore(deps.DB), plugins.NewInstallationStore(deps.DB), - plugins.NewRuntimeConfigStore(deps.DB), + plugins.NewRuntimeConfigStore(deps.DB, deps.SecretCipher), deps.PluginService, deps.PluginUserConfig, deps.PluginHTTPProxy, @@ -2881,7 +2886,9 @@ func NewRouter(deps Dependencies) chi.Router { // config; otherwise disabling rate limiting and restarting would // lock the settings page out of re-enabling it. if settingsRepo != nil { - rateLimitHandler := handlers.NewRateLimitHandler(settingsRepo, deps.RateLimitMW, deps.EventBus, restartStatus) + rateLimitHandler := handlers.NewRateLimitHandler( + settingsRepo, deps.RateLimitMW, deps.EventBus, restartStatus, deps.RedisBootstrapAvailable, + ) r.Route("/rate-limits", func(r chi.Router) { r.Get("/config", rateLimitHandler.HandleGetConfig) r.Put("/config", rateLimitHandler.HandleUpdateConfig) diff --git a/internal/catalog/encrypted_settings_repo.go b/internal/catalog/encrypted_settings_repo.go index 8af2f40c..781159f8 100644 --- a/internal/catalog/encrypted_settings_repo.go +++ b/internal/catalog/encrypted_settings_repo.go @@ -157,6 +157,13 @@ type settingsBatchWriter interface { SetMany(ctx context.Context, values map[string]string) error } +type settingsAtomicUpdater interface { + UpdateAtomic( + ctx context.Context, + update func(current map[string]string) (map[string]string, error), + ) error +} + // SetMany encrypts every sensitive member before delegating one atomic batch // to the raw settings repository. func (r *EncryptedSettingsRepo) SetMany(ctx context.Context, values map[string]string) error { @@ -178,6 +185,45 @@ func (r *EncryptedSettingsRepo) SetMany(ctx context.Context, values map[string]s return inner.SetMany(ctx, encrypted) } +// UpdateAtomic preserves the raw repository's cross-process serialization +// while presenting plaintext to the validator and encrypting only the returned +// writes before they reach server_settings. +func (r *EncryptedSettingsRepo) UpdateAtomic( + ctx context.Context, + update func(current map[string]string) (map[string]string, error), +) error { + inner, ok := r.inner.(settingsAtomicUpdater) + if !ok { + return fmt.Errorf("settings store does not support atomic updates") + } + return inner.UpdateAtomic(ctx, func(rawCurrent map[string]string) (map[string]string, error) { + current := make(map[string]string, len(rawCurrent)) + for key, value := range rawCurrent { + plain, err := r.cipher.DecryptIfEncrypted(value, secret.SettingsAAD(key)) + if err != nil { + return nil, fmt.Errorf("decrypt setting %q: %w", key, err) + } + current[key] = plain + } + writes, err := update(current) + if err != nil { + return nil, err + } + encrypted := make(map[string]string, len(writes)) + for key, value := range writes { + if SensitiveSettingKeys[key] && value != "" { + ciphertext, err := r.cipher.Encrypt(value, secret.SettingsAAD(key)) + if err != nil { + return nil, fmt.Errorf("encrypt setting %q: %w", key, err) + } + value = ciphertext + } + encrypted[key] = value + } + return encrypted, nil + }) +} + // SetIfAbsent applies Set's encryption contract to a conditional write: the // value lands only when the key currently has no value, so concurrent // provisioners of generated secrets cannot overwrite each other. diff --git a/internal/catalog/encrypted_settings_repo_test.go b/internal/catalog/encrypted_settings_repo_test.go index 8f9dfe9b..121e930f 100644 --- a/internal/catalog/encrypted_settings_repo_test.go +++ b/internal/catalog/encrypted_settings_repo_test.go @@ -25,6 +25,20 @@ func (s *memSettings) GetAll(_ context.Context) (map[string]string, error) { } return out, nil } +func (s *memSettings) UpdateAtomic( + _ context.Context, + update func(current map[string]string) (map[string]string, error), +) error { + current, _ := s.GetAll(context.Background()) + writes, err := update(current) + if err != nil { + return err + } + for key, value := range writes { + s.m[key] = value + } + return nil +} func newCipher(t *testing.T) *secret.Cipher { t.Helper() @@ -138,6 +152,38 @@ func TestEncryptedSettings_GetAllDecrypts(t *testing.T) { } } +func TestEncryptedSettings_UpdateAtomicUsesPlaintextContract(t *testing.T) { + ctx := context.Background() + raw := newMemSettings() + dec := NewEncryptedSettingsRepo(raw, newCipher(t)) + if err := dec.Set(ctx, "tmdb.api_key", "old-secret"); err != nil { + t.Fatalf("Set: %v", err) + } + + err := dec.UpdateAtomic(ctx, func(current map[string]string) (map[string]string, error) { + if current["tmdb.api_key"] != "old-secret" { + t.Fatalf("callback value = %q, want plaintext", current["tmdb.api_key"]) + } + return map[string]string{ + "tmdb.api_key": "new-secret", + "server.log_level": "debug", + }, nil + }) + if err != nil { + t.Fatalf("UpdateAtomic: %v", err) + } + if !secret.IsEncrypted(raw.m["tmdb.api_key"]) { + t.Fatalf("raw sensitive value = %q, want ciphertext", raw.m["tmdb.api_key"]) + } + if raw.m["server.log_level"] != "debug" { + t.Fatalf("raw non-sensitive value = %q, want debug", raw.m["server.log_level"]) + } + got, err := dec.Get(ctx, "tmdb.api_key") + if err != nil || got != "new-secret" { + t.Fatalf("Get = %q, %v; want new-secret", got, err) + } +} + // TestSensitiveSettingKeys_Audited locks the audited allowlist: a dropped key is // a plaintext leak (and breaks redaction), so the critical secrets must stay // present, while values that are NOT secrets must stay absent (encrypting them diff --git a/internal/catalog/server_settings_repo.go b/internal/catalog/server_settings_repo.go index 3d340e46..b12e9cf5 100644 --- a/internal/catalog/server_settings_repo.go +++ b/internal/catalog/server_settings_repo.go @@ -3,10 +3,14 @@ package catalog import ( "context" "fmt" + "sort" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) +const serverSettingsMutationLock = "silo:server_settings:mutation" + // ServerSettingsRepo provides CRUD access to the server_settings table. type ServerSettingsRepo struct { pool *pgxpool.Pool @@ -34,61 +38,89 @@ func (r *ServerSettingsRepo) Get(ctx context.Context, key string) (string, error // Set upserts a setting. func (r *ServerSettingsRepo) Set(ctx context.Context, key, value string) error { - _, err := r.pool.Exec(ctx, - `INSERT INTO server_settings (key, value) VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, - key, value, - ) - if err != nil { - return fmt.Errorf("server_settings set %q: %w", key, err) - } - return nil + return r.withMutationTransaction(ctx, func(tx pgx.Tx) error { + return upsertServerSettings(ctx, tx, map[string]string{key: value}) + }) } // SetMany atomically upserts a related group of settings. Credential bundles // use this so readers can never observe a URL, identifier, or secret from // different relay generations after a partial write. func (r *ServerSettingsRepo) SetMany(ctx context.Context, values map[string]string) error { + return r.withMutationTransaction(ctx, func(tx pgx.Tx) error { + return upsertServerSettings(ctx, tx, values) + }) +} + +// UpdateAtomic serializes a read/validate/write settings mutation across every +// Silo process sharing the database. The callback receives the current +// snapshot while the transaction-scoped advisory lock is held and returns the +// subset of values to upsert. +func (r *ServerSettingsRepo) UpdateAtomic( + ctx context.Context, + update func(current map[string]string) (map[string]string, error), +) error { + return r.withMutationTransaction(ctx, func(tx pgx.Tx) error { + current, err := getAllServerSettings(ctx, tx) + if err != nil { + return err + } + writes, err := update(current) + if err != nil { + return err + } + return upsertServerSettings(ctx, tx, writes) + }) +} + +func (r *ServerSettingsRepo) withMutationTransaction( + ctx context.Context, + mutate func(tx pgx.Tx) error, +) error { tx, err := r.pool.Begin(ctx) if err != nil { - return fmt.Errorf("server_settings begin batch: %w", err) + return fmt.Errorf("server_settings begin mutation: %w", err) } defer func() { _ = tx.Rollback(ctx) }() - for key, value := range values { - if _, err := tx.Exec(ctx, - `INSERT INTO server_settings (key, value) VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, - key, value, - ); err != nil { - return fmt.Errorf("server_settings batch set %q: %w", key, err) - } + if _, err := tx.Exec(ctx, + `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, + serverSettingsMutationLock, + ); err != nil { + return fmt.Errorf("server_settings acquire mutation lock: %w", err) + } + if err := mutate(tx); err != nil { + return err } if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("server_settings commit batch: %w", err) + return fmt.Errorf("server_settings commit mutation: %w", err) } return nil } -// SetIfAbsent inserts a setting only when the key has no value yet (absent or -// empty), reporting whether this call won the write. Generated credentials -// (e.g. the web push VAPID keypair) must be provisioned single-writer across -// concurrent nodes: exactly one generated value may ever land. -func (r *ServerSettingsRepo) SetIfAbsent(ctx context.Context, key, value string) (bool, error) { - tag, err := r.pool.Exec(ctx, - `INSERT INTO server_settings (key, value) VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value - WHERE server_settings.value = ''`, - key, value, - ) - if err != nil { - return false, fmt.Errorf("server_settings set-if-absent %q: %w", key, err) - } - return tag.RowsAffected() > 0, nil +type serverSettingsQuerier interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) } -// GetAll retrieves all settings as a map. -func (r *ServerSettingsRepo) GetAll(ctx context.Context) (map[string]string, error) { - rows, err := r.pool.Query(ctx, `SELECT key, value FROM server_settings`) +func upsertServerSettings(ctx context.Context, tx pgx.Tx, values map[string]string) error { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if _, err := tx.Exec(ctx, + `INSERT INTO server_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + key, values[key], + ); err != nil { + return fmt.Errorf("server_settings set %q: %w", key, err) + } + } + return nil +} + +func getAllServerSettings(ctx context.Context, querier serverSettingsQuerier) (map[string]string, error) { + rows, err := querier.Query(ctx, `SELECT key, value FROM server_settings`) if err != nil { return nil, fmt.Errorf("server_settings get all: %w", err) } @@ -96,14 +128,41 @@ func (r *ServerSettingsRepo) GetAll(ctx context.Context) (map[string]string, err settings := make(map[string]string) for rows.Next() { - var k, v string - if err := rows.Scan(&k, &v); err != nil { + var key, value string + if err := rows.Scan(&key, &value); err != nil { return nil, fmt.Errorf("server_settings scan: %w", err) } - settings[k] = v + settings[key] = value } if err := rows.Err(); err != nil { return nil, fmt.Errorf("server_settings iterate: %w", err) } return settings, nil } + +// SetIfAbsent inserts a setting only when the key has no value yet (absent or +// empty), reporting whether this call won the write. Generated credentials +// (e.g. the web push VAPID keypair) must be provisioned single-writer across +// concurrent nodes: exactly one generated value may ever land. +func (r *ServerSettingsRepo) SetIfAbsent(ctx context.Context, key, value string) (bool, error) { + var inserted bool + err := r.withMutationTransaction(ctx, func(tx pgx.Tx) error { + tag, err := tx.Exec(ctx, + `INSERT INTO server_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + WHERE server_settings.value = ''`, + key, value, + ) + if err != nil { + return fmt.Errorf("server_settings set-if-absent %q: %w", key, err) + } + inserted = tag.RowsAffected() > 0 + return nil + }) + return inserted, err +} + +// GetAll retrieves all settings as a map. +func (r *ServerSettingsRepo) GetAll(ctx context.Context) (map[string]string, error) { + return getAllServerSettings(ctx, r.pool) +} diff --git a/internal/config/admin_settings.go b/internal/config/admin_settings.go new file mode 100644 index 00000000..a3f1aae1 --- /dev/null +++ b/internal/config/admin_settings.go @@ -0,0 +1,608 @@ +package config + +import ( + "encoding/json" + "fmt" + "math" + "net/mail" + "net/url" + "strconv" + "strings" + + redisv9 "github.com/redis/go-redis/v9" + "github.com/robfig/cron/v3" +) + +const cloudflareURLMode = "cloudflare_token" + +// adminSettingDefaults is the effective value shown by the Admin UI when no +// row exists in server_settings. Keep these values aligned with the runtime +// readers that own each setting. The UI must never invent a second set of +// defaults: an untouched form should describe the behavior the server is +// actually running. +var adminSettingDefaults = map[string]string{ + "auth.access_token_expiry": "8h", + "auth.refresh_token_expiry": "30d", + "server.log_level": "info", + "server.log_quiet": "", + "branding.server_name": "Silo", + "branding.login_subtitle": "Sign in with an existing account.", + "clientip.trusted_proxies": "10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, ::1/128", + "theme.catalog_url": DefaultThemeCatalogURL, + + "database.max_connections": "20", + "s3.public_path_style": "true", + "s3.public_url_auth": "presigned", + "s3.public_token_param": "verify", + "s3.public_token_ttl": "10800", + "s3.private_path_style": "true", + "s3.user_db_path_style": "true", + "userdb.backend": "postgres", + "userdb.pool_max_open": "500", + "userdb.idle_timeout": "12h", + + "scanner.workers": "8", + "matcher.workers": "8", + "matcher.batch_size": "500", + "metadata.cache_images": "false", + "markers.mode": "local", + "markers.lazy_playback": "false", + + "playback.ffmpeg_path": "/usr/lib/jellyfin-ffmpeg/ffmpeg", + "playback.transcode_dir": DefaultTranscodeDir, + "playback.hw_accel": "auto", + "playback.transcode_enabled": "true", + "playback.local_transcode_fallback": "true", + "playback.chapter_thumbnail_workers": "1", + "playback.chapter_thumbnail_execution": "local", + "playback.chapter_thumbnail_node_capacity": "1", + "playback.chapter_thumbnail_hdr_policy": "best_effort", + "playback.watched_threshold": "90", + "playback.min_resume_threshold": "5", + "allow_4k_transcode": "false", + "enable_transcode_throttle": "false", + "transcode_throttle_seconds": "300", + + "audiobookshelf_compat.enabled": "true", + "jellyfin_compat.enabled": "true", + "jellyfin_compat.public_url": "http://127.0.0.1:8096", + "jellyfin_compat.emulated_server_version": DefaultJellyfinCompatEmulatedServerVersion, + "jellyfin_compat.server_name": "Silo", + "jellyfin_compat.web_enabled": "true", + "jellyfin_compat.web_version": DefaultJellyfinWebVersion, + "jellyfin_compat.web_install_dir": DefaultJellyfinWebInstallDir, + "jellyfin_compat.session_ttl": "87600h", + "jellyfin_compat.playback_session_ttl": "6h", + + "recommendations.enabled": "false", + "recommendations.embedding_base_url": "http://ollama:11434", + "recommendations.embedding_model": "all-minilm", + "recommendations.embeddings_cron": "0 3 * * *", + "recommendations.taste_profiles_cron": "0 4 * * *", + "recommendations.cowatch_cron": "30 4 * * *", + "recommendations.recommendations_cron": "0 5 * * *", + "recommendations.taste_decay_half_life_days": "180", + "recommendations.diversity_lambda": "0.7", + + "ai.base_url": "https://api.openai.com", + "ai.chat_model": "gpt-4o-mini", + "ai.asr_model": "whisper-1", + "ai.max_concurrent_jobs": "2", + "subtitle_ai.enabled": "false", + "subtitle_ai.transcribe_enabled": "false", + "subtitle_ai.batch_size": "40", + "subtitle_ai.context_neighbors": "2", + "subtitle_ai.asr_chunk_seconds": "600", + "subtitle_ai.transcribe_quota_jobs": "0", + "subtitle_ai.transcribe_quota_period": "day", + "metadata_ai.enabled": "false", + "metadata_ai.on_view": "off", + + "download.enabled": "false", + "download.server_bandwidth_mbps": "0", + "download.user_bandwidth_mbps": "0", + "download.max_concurrent_per_user": "3", + "download.max_per_period": "0", + "download.period_duration": "24h", + "download.transcode_enabled": "false", + "download.max_concurrent_prepares": "2", + "download.artifact_max_bytes": "0", + + "policy.decision_log_verbosity": "digest", + "policy.decision_log_scope_sample_rate": "50", + "policy.decision_log_retention_days": "14", + + "email.enabled": "false", + "email.smtp_port": "587", + "email.smtp_security": "starttls", + "email.from_name": "Silo", + + "notifications.release_events_enabled": "true", + "notifications.fanout_enabled": "true", + "notifications.ui_enabled": "true", + "notifications.fanout.settle_seconds": "30", + "notifications.fanout.max_series_burst": "3", + "notifications.fanout.max_event_age_hours": "72", + "notifications.retention.read_days": "90", + "notifications.retention.unread_days": "180", + "notifications.retention.event_days": "30", + "notifications.webhooks_enabled": "false", + "notifications.webhooks.max_per_profile": "10", + "notifications.webhooks.allow_private_destinations": "false", + "notifications.webhooks.deliveries_per_minute_per_profile": "60", + "notifications.email_enabled": "true", + "notifications.email.allow_per_episode": "true", + "notifications.email.digest_hour": "8", + "notifications.discord_enabled": "false", + "notifications.discord.allow_per_episode": "true", + "notifications.discord.digest_hour": "8", + "notifications.discord.poster_mode": "provider", + "notifications.server_channels_enabled": "true", + "notifications.server_channels.batch_seconds": "300", + "notifications.server_channels.mention_requesters": "false", + "notifications.web_push_enabled": "true", + "notifications.apple_push_delivery_enabled": "false", + "notifications.android_push_delivery_enabled": "false", + + "opslog.retention_days": "7", + "opslog.cleanup_interval_minutes": "15", + "opslog.max_rows": "1000000", + "opslog.max_size_mb": "1024", + "overlays.enabled": "true", + "signup.enabled": "false", + + "catalog.search.provider": "postgres", + "catalog.search.meilisearch.index": "silo_media_items", + "catalog.search.meilisearch.timeout_ms": "800", + "catalog.search.meilisearch.matching_strategy": "last", + "catalog.search.meilisearch.sync_batch_size": "500", + "catalog.search.meilisearch.rebuild_batch_size": "5000", + "catalog.search.meilisearch.rebuild_task_queue_depth": "4", + "catalog.search.meilisearch.semantic_enabled": "false", + "catalog.search.meilisearch.semantic_ratio": "0.5", + "catalog.search.meilisearch.embedder": "silo_recommendations", + "catalog.search.meilisearch.binary_quantized": "false", +} + +var legacyAdminSettingFallbacks = []struct { + canonical string + legacy string +}{ + {"s3.public_endpoint", "s3.operational_endpoint"}, //nolint:goconst // Explicit compatibility pair. + {"s3.public_read_endpoint", "s3.operational_public_endpoint"}, //nolint:goconst // Explicit compatibility pair. + {"s3.public_region", "s3.operational_region"}, //nolint:goconst // Explicit compatibility pair. + {"s3.public_path_style", "s3.operational_path_style"}, //nolint:goconst // Explicit compatibility pair. + {"s3.public_bucket", "s3.operational_bucket"}, //nolint:goconst // Explicit compatibility pair. + {"s3.public_key_prefix", "s3.operational_key_prefix"}, //nolint:goconst // Explicit compatibility pair. + {"s3.public_access_key", "s3.operational_access_key"}, //nolint:goconst // Explicit compatibility pair. + {"s3.public_secret_key", "s3.operational_secret_key"}, //nolint:goconst // Explicit compatibility pair. + {"s3.public_url_auth", "s3.operational_url_auth"}, + {"s3.public_token_secret", "s3.operational_token_secret"}, + {"s3.public_token_param", "s3.operational_token_param"}, + {"s3.public_token_ttl", "s3.operational_token_ttl"}, //nolint:goconst // Explicit compatibility pair. + {"s3.private_endpoint", "s3.operational_endpoint"}, //nolint:goconst // Explicit compatibility pair. + {"s3.private_region", "s3.operational_region"}, + {"s3.private_path_style", "s3.operational_path_style"}, + {"s3.private_bucket", "s3.operational_bucket"}, + {"s3.private_key_prefix", "s3.operational_key_prefix"}, + {"s3.private_access_key", "s3.operational_access_key"}, + {"s3.private_secret_key", "s3.operational_secret_key"}, + {"ai.base_url", "subtitle_ai.base_url"}, //nolint:goconst // Explicit compatibility pair. + {"ai.api_key", "subtitle_ai.api_key"}, //nolint:goconst // Explicit compatibility pair. + {"ai.chat_model", "subtitle_ai.chat_model"}, +} + +// EffectiveAdminSettings overlays persisted values onto the runtime defaults +// used by the Admin UI. An empty persisted value means "use the default" for +// keys that have one, matching stringOr/boolOr/intOr in LoadFromDB. +func EffectiveAdminSettings(stored map[string]string) map[string]string { + effective := make(map[string]string, len(adminSettingDefaults)+len(stored)) + for key, value := range adminSettingDefaults { + effective[key] = value + } + for key, value := range stored { + if value == "" { + if _, hasDefault := adminSettingDefaults[key]; hasDefault { + continue + } + } + effective[key] = value + } + // Preserve the canonical-then-legacy precedence used by LoadFromDB. Apply + // aliases after the stored overlay so an explicitly empty canonical key + // cannot erase a configured legacy fallback. + for _, fallback := range legacyAdminSettingFallbacks { + applyLegacyAdminSettingFallback( + effective, + stored, + fallback.canonical, + fallback.legacy, + ) + } + applyLegacyPositiveIntAdminSettingFallback( + effective, + stored, + "ai.max_concurrent_jobs", + "subtitle_ai.max_concurrent_jobs", + ) + return effective +} + +func applyLegacyAdminSettingFallback(effective, stored map[string]string, canonical, legacy string) { + if stored[canonical] != "" { + return + } + if value := stored[legacy]; value != "" { + effective[canonical] = value + } +} + +func applyLegacyPositiveIntAdminSettingFallback( + effective, + stored map[string]string, + canonical, + legacy string, +) { + value := stored[canonical] + if value != "" { + parsed, err := strconv.Atoi(value) + if err != nil || parsed > 0 { + return + } + } + if fallback := stored[legacy]; fallback != "" { + effective[canonical] = fallback + return + } + if fallback, ok := adminSettingDefaults[canonical]; ok { + effective[canonical] = fallback + } +} + +// NormalizeAdminSetting validates and canonicalizes settings shared by the +// generic single and batch Admin endpoints. Domain-specific validators may +// layer stricter checks on top of this function. +func NormalizeAdminSetting(key, raw string) (string, error) { + value := strings.TrimSpace(raw) + + switch key { + case "metadata.cache_images", "playback.transcode_enabled", "playback.local_transcode_fallback", + "allow_4k_transcode", "enable_transcode_throttle", "audiobookshelf_compat.enabled", + "jellyfin_compat.enabled", "jellyfin_compat.web_enabled", "recommendations.enabled", + "subtitle_ai.enabled", "subtitle_ai.transcribe_enabled", "metadata_ai.enabled", + "download.enabled", "download.transcode_enabled", "email.enabled", "signup.enabled", + "overlays.enabled", "notifications.release_events_enabled", "notifications.fanout_enabled", + "notifications.ui_enabled", "notifications.webhooks_enabled", + "notifications.webhooks.allow_private_destinations", "notifications.email_enabled", + "notifications.email.allow_per_episode", "notifications.discord_enabled", + "notifications.discord.allow_per_episode", "notifications.server_channels_enabled", + "notifications.server_channels.mention_requesters", "notifications.web_push_enabled", + "notifications.apple_push_delivery_enabled", "notifications.android_push_delivery_enabled", + "catalog.search.meilisearch.semantic_enabled", "catalog.search.meilisearch.binary_quantized", + "s3.public_path_style", "s3.private_path_style", "s3.user_db_path_style": + return normalizeAdminBool(key, value) + + case "database.max_connections": + return normalizeAdminInt(key, value, 1, 10000) + case "userdb.pool_max_open": + return normalizeAdminInt(key, value, 1, 100000) + case "scanner.workers", "matcher.workers": + return normalizeAdminInt(key, value, 1, 1024) + case "matcher.batch_size": + return normalizeAdminInt(key, value, 1, 100000) + case "playback.chapter_thumbnail_workers", "playback.chapter_thumbnail_node_capacity": + return normalizeAdminInt(key, value, 1, 1024) + case "playback.watched_threshold": + return normalizeAdminInt(key, value, 1, 100) + case "playback.min_resume_threshold": + return normalizeAdminInt(key, value, 1, 99) + case "transcode_throttle_seconds": + return normalizeAdminInt(key, value, 60, 86400) + case "ai.max_concurrent_jobs", "subtitle_ai.max_concurrent_jobs": + return normalizeAdminInt(key, value, 1, 1024) + case "subtitle_ai.batch_size": + return normalizeAdminInt(key, value, 1, 1000) + case "subtitle_ai.context_neighbors": + return normalizeAdminInt(key, value, 0, 100) + case "subtitle_ai.asr_chunk_seconds": + return normalizeAdminInt(key, value, 60, 600) + case "subtitle_ai.transcribe_quota_jobs": + return normalizeAdminInt(key, value, 0, math.MaxInt32) + case "download.server_bandwidth_mbps", "download.user_bandwidth_mbps": + return normalizeAdminInt64(key, value, 0, 73_786_976_294_838) + case "download.max_concurrent_per_user", "download.max_per_period", + "download.max_concurrent_prepares", "download.artifact_max_bytes": + return normalizeAdminInt64(key, value, 0, math.MaxInt64) + case "policy.decision_log_scope_sample_rate", "policy.decision_log_retention_days": + return normalizeAdminInt(key, value, 1, math.MaxInt32) + case "email.smtp_port": + return normalizeAdminInt(key, value, 1, 65535) + case "notifications.fanout.settle_seconds": + return normalizeAdminInt(key, value, 0, 3600) + case "notifications.fanout.max_series_burst": + return normalizeAdminInt(key, value, 1, 1000) + case "notifications.fanout.max_event_age_hours": + return normalizeAdminInt(key, value, 1, 24*365) + case "notifications.retention.read_days", "notifications.retention.unread_days", + "notifications.retention.event_days": + return normalizeAdminInt(key, value, 1, 3650) + case "notifications.webhooks.max_per_profile": + return normalizeAdminInt(key, value, 1, 100) + case "notifications.webhooks.deliveries_per_minute_per_profile": + return normalizeAdminInt(key, value, 1, 10000) + case "notifications.email.digest_hour", "notifications.discord.digest_hour": + return normalizeAdminInt(key, value, 0, 23) + case "notifications.server_channels.batch_seconds": + return normalizeAdminInt(key, value, 120, 3600) + case "catalog.search.meilisearch.timeout_ms": + return normalizeAdminInt(key, value, 1, math.MaxInt32) + case "catalog.search.meilisearch.sync_batch_size": + return normalizeAdminInt(key, value, 1, 10000) + case "catalog.search.meilisearch.rebuild_batch_size": + return normalizeAdminInt(key, value, 1, 25000) + case "catalog.search.meilisearch.rebuild_task_queue_depth": + return normalizeAdminInt(key, value, 1, 16) + case "opslog.retention_days", "opslog.cleanup_interval_minutes": + return normalizeAdminInt(key, value, 1, math.MaxInt32) + case "opslog.max_rows", "opslog.max_size_mb": + return normalizeAdminInt64(key, value, 1, math.MaxInt64) + case "s3.public_token_ttl": + return normalizeAdminInt(key, value, 1, math.MaxInt32) + + case "recommendations.taste_decay_half_life_days": + return normalizeAdminFloat(key, value, math.SmallestNonzeroFloat64, math.MaxFloat64) + case "recommendations.diversity_lambda", "catalog.search.meilisearch.semantic_ratio": + return normalizeAdminFloat(key, value, 0, 1) + + case "auth.access_token_expiry", "auth.refresh_token_expiry", "userdb.idle_timeout", + "download.period_duration", "jellyfin_compat.session_ttl", + "jellyfin_compat.playback_session_ttl": + return normalizeAdminDuration(key, value) + + case "server.log_level": + return normalizeAdminEnum(key, value, "debug", "info", "warn", "error") + case "userdb.backend": + return normalizeAdminEnum(key, value, "postgres", "sqlite") + case "playback.hw_accel": + return normalizeAdminEnum(key, value, "auto", "qsv", "vaapi", "nvenc", "none") + case "playback.chapter_thumbnail_execution": + return normalizeAdminEnum(key, value, "local", "prefer_transcode_nodes", "transcode_nodes_only") + case "playback.chapter_thumbnail_hdr_policy": + return normalizeAdminEnum(key, value, "disabled", "best_effort") + case "metadata_ai.on_view": + return normalizeAdminEnum(key, value, "off", "button", "auto") + case "subtitle_ai.transcribe_quota_period": + return normalizeAdminEnum(key, value, "day", "week", "month") + case "policy.decision_log_verbosity": + return normalizeAdminEnum(key, value, "digest", "verbose") + case "email.smtp_security": + return normalizeAdminEnum(key, value, "starttls", "tls", "none") + case "notifications.discord.poster_mode": + return normalizeAdminEnum(key, value, "off", "provider", "server") + case "catalog.search.provider": + return normalizeAdminEnum(key, value, "postgres", "meilisearch") + case "catalog.search.meilisearch.matching_strategy": + return normalizeAdminEnum(key, value, "last", "all") + case "s3.public_url_auth": + return normalizeAdminEnum(key, value, "", "presigned", "public", "cloudflare_token") + + case "recommendations.embeddings_cron", "recommendations.taste_profiles_cron", + "recommendations.cowatch_cron", "recommendations.recommendations_cron": + if _, err := cron.ParseStandard(value); err != nil { + return "", fmt.Errorf("%s must be a valid five-field cron expression: %w", key, err) + } + return value, nil + + case "ai.base_url", "ai.asr_base_url", "recommendations.embedding_base_url", + "jellyfin_compat.public_url", "notifications.email.external_url", + "s3.public_endpoint", "s3.public_read_endpoint", "s3.private_endpoint", + "s3.user_db_endpoint", "catalog.search.meilisearch.url": + return normalizeAdminURL(key, value) + case "redis.url": + return NormalizeRedisURL(value) + case "theme.catalog_url": + return normalizeAdminThemeURL(key, value) + + case "email.from_address": + if value == "" { + return "", nil + } + address, err := mail.ParseAddress(value) + if err != nil || address.Address != value { + return "", fmt.Errorf("%s must be a valid email address", key) + } + return value, nil + + case "defaults.card_overlays", "opslog.bucket_policies", "ui.admin_theme_vars": + if value == "" { + return "", nil + } + var decoded any + if err := json.Unmarshal([]byte(value), &decoded); err != nil { + return "", fmt.Errorf("%s must be valid JSON: %w", key, err) + } + return value, nil + } + + return raw, nil +} + +// AdminSettingsCapabilities describes durable bootstrap configuration that is +// intentionally absent from the flat server_settings map. +type AdminSettingsCapabilities struct { + RedisBootstrapAvailable bool +} + +// ValidateAdminSettings validates a stored settings snapshot without external +// bootstrap capabilities. +func ValidateAdminSettings(values map[string]string) error { + return ValidateAdminSettingsWithCapabilities(values, AdminSettingsCapabilities{}) +} + +// ValidateAdminSettingsWithCapabilities validates the complete prospective +// settings snapshot against durable bootstrap configuration. It catches +// combinations that only become invalid once independently editable fields are +// considered together. +func ValidateAdminSettingsWithCapabilities(values map[string]string, capabilities AdminSettingsCapabilities) error { + if _, err := LoadFromDB(values); err != nil { + return err + } + + access, err := parseDuration(EffectiveAdminSettings(values)["auth.access_token_expiry"]) + if err != nil || access <= 0 { + return fmt.Errorf("auth.access_token_expiry must be a positive duration") + } + refresh, err := parseDuration(EffectiveAdminSettings(values)["auth.refresh_token_expiry"]) + if err != nil || refresh <= 0 { + return fmt.Errorf("auth.refresh_token_expiry must be a positive duration") + } + if refresh < access { + return fmt.Errorf("auth.refresh_token_expiry must be greater than or equal to auth.access_token_expiry") + } + + if watched, _ := strconv.Atoi(EffectiveAdminSettings(values)["playback.watched_threshold"]); watched > 0 { + if resume, _ := strconv.Atoi(EffectiveAdminSettings(values)["playback.min_resume_threshold"]); resume >= watched { + return fmt.Errorf("playback.min_resume_threshold must be less than playback.watched_threshold") + } + } + + effective := EffectiveAdminSettings(values) + for _, prefix := range []string{"s3.public", "s3.private"} { + endpoint := strings.TrimSpace(effective[prefix+"_endpoint"]) + bucket := strings.TrimSpace(effective[prefix+"_bucket"]) + if (endpoint == "") != (bucket == "") { + return fmt.Errorf("%s endpoint and bucket must be configured together", strings.ReplaceAll(prefix, ".", " ")) + } + accessKey := strings.TrimSpace(effective[prefix+"_access_key"]) + secretKey := strings.TrimSpace(effective[prefix+"_secret_key"]) + if (accessKey == "") != (secretKey == "") { + return fmt.Errorf("%s access key and secret key must be configured together", strings.ReplaceAll(prefix, ".", " ")) + } + } + + switch effective["s3.public_url_auth"] { + case "", "presigned": + case "public", cloudflareURLMode: + if strings.TrimSpace(effective["s3.public_read_endpoint"]) == "" { + return fmt.Errorf("s3.public_read_endpoint is required for %s URL authentication", effective["s3.public_url_auth"]) + } + if effective["s3.public_url_auth"] == cloudflareURLMode && strings.TrimSpace(effective["s3.public_token_secret"]) == "" { + return fmt.Errorf("s3.public_token_secret is required for Cloudflare Token URL authentication") + } + default: + return fmt.Errorf("s3.public_url_auth must be presigned, public, or cloudflare_token") + } + if effective["email.enabled"] == "true" { + if strings.TrimSpace(effective["email.smtp_host"]) == "" { + return fmt.Errorf("email.smtp_host is required when email is enabled") + } + if strings.TrimSpace(effective["email.from_address"]) == "" { + return fmt.Errorf("email.from_address is required when email is enabled") + } + } + for _, provider := range []string{"trakt", "simkl"} { + clientID := strings.TrimSpace(effective["watchsync."+provider+".client_id"]) + clientSecret := strings.TrimSpace(effective["watchsync."+provider+".client_secret"]) + if (clientID == "") != (clientSecret == "") { + return fmt.Errorf("watchsync.%s client ID and client secret must be configured together", provider) + } + } + if err := ValidateRedisRateLimitTransport(effective, capabilities.RedisBootstrapAvailable); err != nil { + return err + } + + return nil +} + +// ValidateRedisRateLimitTransport ensures a persisted Redis limiter selection +// will still have a usable transport after restart. Active process state is not +// sufficient: it may be using a URL that this same update clears. +func ValidateRedisRateLimitTransport(values map[string]string, redisBootstrapAvailable bool) error { + effective := EffectiveAdminSettings(values) + redisURL, err := NormalizeRedisURL(effective["redis.url"]) + if err != nil { + return err + } + if strings.EqualFold(strings.TrimSpace(effective["ratelimit.backend"]), "redis") && + redisURL == "" && + !redisBootstrapAvailable { + return fmt.Errorf("redis.url or a bootstrap Redis/Sentinel transport is required when ratelimit.backend is redis") + } + return nil +} + +// NormalizeRedisURL applies the same parser used by the runtime Redis client. +func NormalizeRedisURL(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", nil + } + if _, err := redisv9.ParseURL(value); err != nil { + return "", fmt.Errorf("redis.url must be a valid redis://, rediss://, or unix:// URL: %w", err) + } + return value, nil +} + +func normalizeAdminBool(key, value string) (string, error) { + parsed, err := strconv.ParseBool(strings.ToLower(value)) + if err != nil { + return "", fmt.Errorf("%s must be true or false", key) + } + return strconv.FormatBool(parsed), nil +} + +func normalizeAdminInt(key, value string, minValue, maxValue int) (string, error) { + parsed, err := strconv.Atoi(value) + if err != nil || parsed < minValue || parsed > maxValue { + return "", fmt.Errorf("%s must be an integer between %d and %d", key, minValue, maxValue) + } + return strconv.Itoa(parsed), nil +} + +func normalizeAdminInt64(key, value string, minValue, maxValue int64) (string, error) { + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < minValue || parsed > maxValue { + return "", fmt.Errorf("%s must be an integer between %d and %d", key, minValue, maxValue) + } + return strconv.FormatInt(parsed, 10), nil +} + +func normalizeAdminFloat(key, value string, minValue, maxValue float64) (string, error) { + parsed, err := strconv.ParseFloat(value, 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed < minValue || parsed > maxValue { + return "", fmt.Errorf("%s must be a number between %g and %g", key, minValue, maxValue) + } + return strconv.FormatFloat(parsed, 'f', -1, 64), nil +} + +func normalizeAdminDuration(key, value string) (string, error) { + parsed, err := parseDuration(value) + if err != nil || parsed <= 0 { + return "", fmt.Errorf("%s must be a positive duration", key) + } + return value, nil +} + +func normalizeAdminEnum(key, value string, allowed ...string) (string, error) { + normalized := strings.ToLower(value) + for _, candidate := range allowed { + if normalized == candidate { + return normalized, nil + } + } + return "", fmt.Errorf("%s must be one of: %s", key, strings.Join(allowed, ", ")) +} + +func normalizeAdminURL(key, value string) (string, error) { + if value == "" { + return "", nil + } + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("%s must include a URL scheme and host", key) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("%s must use http or https", key) + } + return strings.TrimRight(value, "/"), nil +} diff --git a/internal/config/admin_settings_test.go b/internal/config/admin_settings_test.go new file mode 100644 index 00000000..6351534e --- /dev/null +++ b/internal/config/admin_settings_test.go @@ -0,0 +1,292 @@ +//nolint:goconst // Settings contract tests intentionally repeat literal keys in input and expected maps. +package config + +import ( + "reflect" + "strings" + "testing" +) + +func TestEffectiveAdminSettingsUsesRuntimeDefaults(t *testing.T) { + effective := EffectiveAdminSettings(map[string]string{ + "database.max_connections": "", + "server.log_level": "debug", + "custom.setting": "kept", + }) + + if got := effective["database.max_connections"]; got != "20" { + t.Fatalf("database.max_connections = %q, want 20", got) + } + if got := effective["s3.public_path_style"]; got != "true" { + t.Fatalf("s3.public_path_style = %q, want true", got) + } + if got := effective["playback.transcode_enabled"]; got != "true" { + t.Fatalf("playback.transcode_enabled = %q, want true", got) + } + if got := effective["theme.catalog_url"]; got != DefaultThemeCatalogURL { + t.Fatalf("theme.catalog_url = %q, want %q", got, DefaultThemeCatalogURL) + } + if got := effective["server.log_level"]; got != "debug" { + t.Fatalf("server.log_level = %q, want debug", got) + } + if got := effective["custom.setting"]; got != "kept" { + t.Fatalf("custom.setting = %q, want kept", got) + } +} + +func TestEffectiveAdminSettingsUsesLegacyS3FallbacksBeforeDefaults(t *testing.T) { + effective := EffectiveAdminSettings(map[string]string{ + "s3.operational_path_style": "false", + "s3.operational_token_ttl": "3600", + }) + + if got := effective["s3.public_path_style"]; got != "false" { + t.Fatalf("s3.public_path_style = %q, want legacy false", got) + } + if got := effective["s3.private_path_style"]; got != "false" { + t.Fatalf("s3.private_path_style = %q, want legacy false", got) + } + if got := effective["s3.public_token_ttl"]; got != "3600" { + t.Fatalf("s3.public_token_ttl = %q, want legacy 3600", got) + } +} + +func TestEffectiveAdminSettingsCanonicalS3ValuesOverrideLegacyFallbacks(t *testing.T) { + effective := EffectiveAdminSettings(map[string]string{ + "s3.public_path_style": "true", + "s3.private_path_style": "false", + "s3.operational_path_style": "true", + "s3.public_token_ttl": "7200", + "s3.operational_token_ttl": "3600", + }) + + if got := effective["s3.public_path_style"]; got != "true" { + t.Fatalf("s3.public_path_style = %q, want canonical true", got) + } + if got := effective["s3.private_path_style"]; got != "false" { + t.Fatalf("s3.private_path_style = %q, want canonical false", got) + } + if got := effective["s3.public_token_ttl"]; got != "7200" { + t.Fatalf("s3.public_token_ttl = %q, want canonical 7200", got) + } +} + +func TestEffectiveAdminSettingsEmptyCanonicalS3ValuesUseLegacyFallbacks(t *testing.T) { + effective := EffectiveAdminSettings(map[string]string{ + "s3.public_path_style": "", + "s3.private_path_style": "", + "s3.operational_path_style": "false", + "s3.public_token_ttl": "", + "s3.operational_token_ttl": "3600", + }) + + if got := effective["s3.public_path_style"]; got != "false" { + t.Fatalf("s3.public_path_style = %q, want legacy false", got) + } + if got := effective["s3.private_path_style"]; got != "false" { + t.Fatalf("s3.private_path_style = %q, want legacy false", got) + } + if got := effective["s3.public_token_ttl"]; got != "3600" { + t.Fatalf("s3.public_token_ttl = %q, want legacy 3600", got) + } +} + +func TestEffectiveAdminSettingsProjectsRuntimeLegacyFallbacks(t *testing.T) { + stored := map[string]string{ + "s3.operational_endpoint": "https://s3.example.invalid", + "s3.operational_public_endpoint": "https://cdn.example.invalid", + "s3.operational_region": "us-test-1", + "s3.operational_path_style": "false", + "s3.operational_bucket": "legacy-bucket", + "s3.operational_key_prefix": "legacy-prefix", + "s3.operational_access_key": "legacy-access", + "s3.operational_secret_key": "legacy-secret", + "s3.operational_url_auth": "presigned", + "s3.operational_token_secret": "legacy-token", + "s3.operational_token_param": "signature", + "s3.operational_token_ttl": "3600", + "subtitle_ai.base_url": "https://legacy-ai.example.invalid", + "subtitle_ai.api_key": "legacy-ai-key", + "subtitle_ai.chat_model": "legacy-chat-model", + "subtitle_ai.max_concurrent_jobs": "7", + "ai.max_concurrent_jobs": "0", + } + + effective := EffectiveAdminSettings(stored) + expected := map[string]string{ + "s3.public_endpoint": stored["s3.operational_endpoint"], + "s3.public_read_endpoint": stored["s3.operational_public_endpoint"], + "s3.public_region": stored["s3.operational_region"], + "s3.public_path_style": stored["s3.operational_path_style"], + "s3.public_bucket": stored["s3.operational_bucket"], + "s3.public_key_prefix": stored["s3.operational_key_prefix"], + "s3.public_access_key": stored["s3.operational_access_key"], + "s3.public_secret_key": stored["s3.operational_secret_key"], + "s3.public_url_auth": stored["s3.operational_url_auth"], + "s3.public_token_secret": stored["s3.operational_token_secret"], + "s3.public_token_param": stored["s3.operational_token_param"], + "s3.public_token_ttl": stored["s3.operational_token_ttl"], + "s3.private_endpoint": stored["s3.operational_endpoint"], + "s3.private_region": stored["s3.operational_region"], + "s3.private_path_style": stored["s3.operational_path_style"], + "s3.private_bucket": stored["s3.operational_bucket"], + "s3.private_key_prefix": stored["s3.operational_key_prefix"], + "s3.private_access_key": stored["s3.operational_access_key"], + "s3.private_secret_key": stored["s3.operational_secret_key"], + "ai.base_url": stored["subtitle_ai.base_url"], + "ai.api_key": stored["subtitle_ai.api_key"], + "ai.chat_model": stored["subtitle_ai.chat_model"], + "ai.max_concurrent_jobs": stored["subtitle_ai.max_concurrent_jobs"], + } + for key, want := range expected { + if got := effective[key]; got != want { + t.Errorf("%s = %q, want legacy fallback %q", key, got, want) + } + } +} + +func TestEffectiveAdminSettingsUsesRuntimeDefaultForNonpositiveCanonicalAIConcurrency(t *testing.T) { + for _, canonical := range []string{"0", "-1"} { + t.Run(canonical, func(t *testing.T) { + effective := EffectiveAdminSettings(map[string]string{ + "ai.max_concurrent_jobs": canonical, + }) + if got := effective["ai.max_concurrent_jobs"]; got != "2" { + t.Fatalf("ai.max_concurrent_jobs = %q, want runtime default 2", got) + } + }) + } +} + +func TestAdminSettingDefaultsAlignWithConfigRuntimeDefaults(t *testing.T) { + baseline, err := LoadFromDB(nil) + if err != nil { + t.Fatal(err) + } + normalizeEffectiveRuntimeDefaults(baseline) + + for key, value := range adminSettingDefaults { + t.Run(key, func(t *testing.T) { + withExplicitDefault, err := LoadFromDB(map[string]string{key: value}) + if err != nil { + t.Fatal(err) + } + normalizeEffectiveRuntimeDefaults(withExplicitDefault) + if !reflect.DeepEqual(withExplicitDefault, baseline) { + t.Fatalf("admin default %q does not match the runtime default", value) + } + }) + } +} + +func normalizeEffectiveRuntimeDefaults(cfg *Config) { + if cfg.S3.Public.URLAuth == "" { + cfg.S3.Public.URLAuth = "presigned" + } + if cfg.S3.Public.TokenParam == "" { + cfg.S3.Public.TokenParam = "verify" + } + if cfg.S3.Public.TokenTTL <= 0 { + cfg.S3.Public.TokenTTL = 10800 + } + // The client IP loader treats an empty value as its built-in private-range + // default. Normalize formatting as well so equivalent CIDR lists compare. + cfg.ClientIP.TrustedProxies = strings.ReplaceAll( + firstConfiguredString( + map[string]string{"trusted": cfg.ClientIP.TrustedProxies}, + adminSettingDefaults["clientip.trusted_proxies"], + "trusted", + ), + " ", + "", + ) +} + +func TestNormalizeAdminSettingRejectsInvalidValues(t *testing.T) { + tests := []struct { + key string + value string + }{ + {key: "database.max_connections", value: "0"}, + {key: "metadata.cache_images", value: "maybe"}, + {key: "auth.access_token_expiry", value: "forever"}, + {key: "recommendations.embeddings_cron", value: "not a cron"}, + {key: "notifications.server_channels.batch_seconds", value: "119"}, + {key: "catalog.search.meilisearch.semantic_ratio", value: "1.2"}, + {key: "email.smtp_port", value: "70000"}, + {key: "theme.catalog_url", value: "http://raw.githubusercontent.com/Silo-Server/silo-themes/main/catalog.json"}, + {key: "theme.catalog_url", value: "https://example.com/catalog.json"}, + {key: "redis.url", value: "not-a-url"}, + } + for _, tc := range tests { + t.Run(tc.key, func(t *testing.T) { + if _, err := NormalizeAdminSetting(tc.key, tc.value); err == nil { + t.Fatalf("NormalizeAdminSetting(%q, %q) returned nil error", tc.key, tc.value) + } + }) + } +} + +func TestNormalizeAdminSettingAcceptsApprovedThemeCatalogURL(t *testing.T) { + got, err := NormalizeAdminSetting( + "theme.catalog_url", + "https://raw.githubusercontent.com/Silo-Server/silo-themes/main/catalog.json/", + ) + if err != nil { + t.Fatal(err) + } + if got != "https://raw.githubusercontent.com/Silo-Server/silo-themes/main/catalog.json" { + t.Fatalf("normalized URL = %q", got) + } +} + +func TestValidateAdminSettingsChecksProspectiveRelationships(t *testing.T) { + values := map[string]string{ + "auth.access_token_expiry": "48h", + "auth.refresh_token_expiry": "24h", + "playback.watched_threshold": "90", + "playback.min_resume_threshold": "5", + } + if err := ValidateAdminSettings(values); err == nil { + t.Fatal("ValidateAdminSettings() returned nil for refresh shorter than access") + } + + values["auth.refresh_token_expiry"] = "72h" + values["playback.min_resume_threshold"] = "95" + if err := ValidateAdminSettings(values); err == nil { + t.Fatal("ValidateAdminSettings() returned nil for resume threshold above watched") + } +} + +func TestValidateAdminSettingsRequiresDurableRedisTransport(t *testing.T) { + values := map[string]string{"ratelimit.backend": "redis"} + if err := ValidateAdminSettings(values); err == nil { + t.Fatal("ValidateAdminSettings() accepted Redis backend without a durable transport") + } + + if err := ValidateAdminSettingsWithCapabilities(values, AdminSettingsCapabilities{ + RedisBootstrapAvailable: true, + }); err != nil { + t.Fatalf("bootstrap Sentinel transport was rejected: %v", err) + } + + values["redis.url"] = "redis://cache.example.invalid:6379" + if err := ValidateAdminSettings(values); err != nil { + t.Fatalf("persisted Redis URL was rejected: %v", err) + } + + values["redis.url"] = "not-a-url" + if err := ValidateAdminSettings(values); err == nil { + t.Fatal("ValidateAdminSettings() accepted a malformed Redis URL") + } +} + +func TestNormalizeAdminSettingCanonicalizesRedisURL(t *testing.T) { + got, err := NormalizeAdminSetting("redis.url", " rediss://cache.example.invalid:6380/2 ") + if err != nil { + t.Fatal(err) + } + if got != "rediss://cache.example.invalid:6380/2" { + t.Fatalf("normalized Redis URL = %q", got) + } +} diff --git a/internal/config/restart_keys.go b/internal/config/restart_keys.go index 5aa7a191..269402e0 100644 --- a/internal/config/restart_keys.go +++ b/internal/config/restart_keys.go @@ -53,6 +53,10 @@ var restartRequiredKeys = map[string]bool{ // External API clients built once at startup. "tmdb.api_key": true, + // The Trakt collection browser captures its public client ID when the + // router is built. Watch-sync flows read both credentials live, but a + // restart is still required for the collection adapter to converge. + "watchsync.trakt.client_id": true, // Compat listeners and session stores. "audiobookshelf_compat.enabled": true, diff --git a/internal/config/theme_url.go b/internal/config/theme_url.go new file mode 100644 index 00000000..7b0cd8e0 --- /dev/null +++ b/internal/config/theme_url.go @@ -0,0 +1,42 @@ +package config + +import ( + "fmt" + "net/url" + "strings" +) + +// DefaultThemeCatalogURL is the catalog fetched by the runtime when no +// override is stored. It is also part of the Admin settings effective-value +// contract, so the UI never has to duplicate or guess the active default. +const DefaultThemeCatalogURL = "https://raw.githubusercontent.com/Silo-Server/silo-themes/main/catalog.json" + +var allowedThemeRemoteHosts = map[string]struct{}{ + "raw.githubusercontent.com": {}, + "github.com": {}, + "objects.githubusercontent.com": {}, +} + +// ValidateThemeRemoteURL applies the shared allowlist used when accepting a +// catalog setting and when fetching theme resources. Keeping this contract in +// one package prevents the Admin UI from persisting a URL the runtime refuses. +func ValidateThemeRemoteURL(parsed *url.URL) error { + if parsed == nil || parsed.Scheme != "https" || parsed.Host == "" { + return url.InvalidHostError("theme URL must use HTTPS on an approved GitHub host") + } + if _, allowed := allowedThemeRemoteHosts[parsed.Hostname()]; !allowed { + return url.InvalidHostError("theme URL must use HTTPS on an approved GitHub host") + } + return nil +} + +func normalizeAdminThemeURL(key, value string) (string, error) { + if value == "" { + return "", nil + } + parsed, err := url.Parse(value) + if err != nil || ValidateThemeRemoteURL(parsed) != nil { + return "", fmt.Errorf("%s must use HTTPS on an approved GitHub host", key) + } + return strings.TrimRight(value, "/"), nil +} diff --git a/internal/logfilter/handler.go b/internal/logfilter/handler.go index 54c5816f..77e674f5 100644 --- a/internal/logfilter/handler.go +++ b/internal/logfilter/handler.go @@ -46,6 +46,7 @@ func parseQuiet(quietCSV string) []string { prefixes := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) + p = strings.TrimSuffix(p, ":") if p != "" { prefixes = append(prefixes, p+":") } diff --git a/internal/logfilter/handler_test.go b/internal/logfilter/handler_test.go index 4cb9363e..71a1b88c 100644 --- a/internal/logfilter/handler_test.go +++ b/internal/logfilter/handler_test.go @@ -31,6 +31,23 @@ func TestHandlerFiltersConfiguredPrefixes(t *testing.T) { } } +func TestHandlerAcceptsOptionalTrailingColonInConfiguredPrefix(t *testing.T) { + buf := &bytes.Buffer{} + h := New(slog.NewTextHandler(buf, nil), "metadata:") + logger := slog.New(h) + + logger.Info("metadata: cached image") + logger.Info("playback: session started") + + out := buf.String() + if strings.Contains(out, "metadata: cached image") { + t.Fatalf("colon-suffixed quiet prefix was treated as metadata:: %s", out) + } + if !strings.Contains(out, "playback: session started") { + t.Fatalf("unrelated message was dropped: %s", out) + } +} + func TestSetQuietAppliesToClones(t *testing.T) { h, buf := newCapture() diff --git a/internal/mdblist/discovery.go b/internal/mdblist/discovery.go index 16579279..e9259044 100644 --- a/internal/mdblist/discovery.go +++ b/internal/mdblist/discovery.go @@ -99,6 +99,13 @@ func (c *Client) Top(ctx context.Context) ([]ListSummary, error) { return c.fetchLists(ctx, "/lists/top", q) } +// Check verifies that the configured API key can reach an authenticated +// discovery endpoint without exposing or persisting any returned list data. +func (c *Client) Check(ctx context.Context) error { + _, err := c.Top(ctx) + return err +} + func (c *Client) fetchLists(ctx context.Context, path string, q url.Values) ([]ListSummary, error) { if !c.Configured() { return nil, ErrNotConfigured diff --git a/internal/notifications/server_channel_service.go b/internal/notifications/server_channel_service.go index 8c58c316..aa0435f2 100644 --- a/internal/notifications/server_channel_service.go +++ b/internal/notifications/server_channel_service.go @@ -64,11 +64,8 @@ func (s *ServerChannelService) List(ctx context.Context) ([]ServerChannel, error // Create validates and persists a new server channel. For generic channels // the returned signingSecret is shown exactly once. func (s *ServerChannelService) Create(ctx context.Context, createdByUserID int, input ServerChannelInput) (*ServerChannel, string, error) { - // The kill switch blocks new destinations; existing channels stay - // manageable (list/update/delete) so a later disable never strands rows. - if !s.settings.ServerChannelsEnabled(ctx) { - return nil, "", ErrServerChannelsDisabled - } + // The kill switch gates delivery in the worker, not configuration. Admins + // can prepare and test destinations while delivery remains disabled. if input.Name == nil || input.URL == nil { return nil, "", fmt.Errorf("%w: name and url are required", ErrServerChannelInvalid) } @@ -258,9 +255,6 @@ func (s *ServerChannelService) RotateSecret(ctx context.Context, id string) (str // Test synchronously POSTs a clearly marked sample content digest. Test sends // never touch the watermark or failure counters. func (s *ServerChannelService) Test(ctx context.Context, id string) (*WebhookTestResult, error) { - if !s.settings.ServerChannelsEnabled(ctx) { - return nil, ErrServerChannelsDisabled - } ch, err := s.repo.GetByID(ctx, id) if err != nil { return nil, err diff --git a/internal/plugins/config_secrets.go b/internal/plugins/config_secrets.go new file mode 100644 index 00000000..32eb53d1 --- /dev/null +++ b/internal/plugins/config_secrets.go @@ -0,0 +1,370 @@ +package plugins + +import ( + "encoding/json" + "sort" + "strconv" + "strings" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" +) + +// GlobalConfigFieldSets returns the top-level fields that are safe to expose +// and those that must be redacted. When an annotation appears below the top +// level, the current flat Admin form redacts the containing object as a unit. +func GlobalConfigFieldSets( + manifest *pluginv1.PluginManifest, + configKey string, +) (publicFields, secretFields []string) { + declared := make(map[string]struct{}) + secrets := make(map[string]struct{}) + schema := globalConfigSchema(manifest, configKey) + if schema != nil { + if form := schema.GetAdminForm(); form != nil { + for _, field := range form.GetFields() { + if field == nil { + continue + } + key := strings.TrimSpace(field.GetKey()) + if key == "" { + continue + } + declared[key] = struct{}{} + if field.GetSecret() || + field.GetControl() == pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_PASSWORD { + secrets[key] = struct{}{} + } + } + } + var document any + if json.Unmarshal([]byte(schema.GetJsonSchema()), &document) == nil { + properties := make(map[string][]any) + collectTopLevelSchemaProperties(document, document, make(map[string]bool), properties) + for key, candidates := range properties { + declared[key] = struct{}{} + for _, candidate := range candidates { + if jsonSchemaContainsSecret(candidate, document, make(map[string]bool)) { + secrets[key] = struct{}{} + break + } + } + } + } + } + publicFields = make([]string, 0, len(declared)) + secretFields = make([]string, 0, len(secrets)) + for key := range declared { + if _, secret := secrets[key]; secret { + continue + } + publicFields = append(publicFields, key) + } + for key := range secrets { + secretFields = append(secretFields, key) + } + sort.Strings(publicFields) + sort.Strings(secretFields) + return publicFields, secretFields +} + +// GlobalConfigSecretFields returns top-level fields the plugin manifest marks +// as credentials, including objects that contain nested credentials. +func GlobalConfigSecretFields(manifest *pluginv1.PluginManifest, configKey string) []string { + _, secrets := GlobalConfigFieldSets(manifest, configKey) + return secrets +} + +// GlobalConfigSecretPaths returns schema-relative paths to actual credential +// values. Top-level redaction remains intentionally broader: an object that +// contains one of these paths is withheld as a unit. +func GlobalConfigSecretPaths(manifest *pluginv1.PluginManifest, configKey string) [][]string { + schema := globalConfigSchema(manifest, configKey) + if schema == nil { + return nil + } + + pathsByKey := make(map[string][]string) + addPath := func(path []string) { + if len(path) == 0 { + return + } + cloned := append([]string(nil), path...) + pathsByKey[strings.Join(cloned, "\x00")] = cloned + } + if form := schema.GetAdminForm(); form != nil { + for _, field := range form.GetFields() { + if field == nil || + (!field.GetSecret() && + field.GetControl() != pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_PASSWORD) { + continue + } + if key := strings.TrimSpace(field.GetKey()); key != "" { + addPath([]string{key}) + } + } + } + + var document any + if json.Unmarshal([]byte(schema.GetJsonSchema()), &document) == nil { + properties := make(map[string][]any) + collectTopLevelSchemaProperties(document, document, make(map[string]bool), properties) + for key, candidates := range properties { + for _, candidate := range candidates { + foundAddressablePath := false + collectJSONSchemaSecretPaths( + candidate, + document, + make(map[string]bool), + []string{key}, + func(path []string) { + foundAddressablePath = true + addPath(path) + }, + ) + if !foundAddressablePath && + jsonSchemaContainsSecret(candidate, document, make(map[string]bool)) { + // A valid schema keyword that this path walker cannot + // address must remain protected as an opaque top-level + // credential rather than silently losing preservation. + addPath([]string{key}) + } + } + } + } + + keys := make([]string, 0, len(pathsByKey)) + for key := range pathsByKey { + keys = append(keys, key) + } + sort.Strings(keys) + paths := make([][]string, 0, len(keys)) + for _, key := range keys { + paths = append(paths, pathsByKey[key]) + } + return paths +} + +func collectJSONSchemaSecretPaths( + node any, + root any, + visitingRefs map[string]bool, + path []string, + addPath func([]string), +) { + object, ok := node.(map[string]any) + if !ok { + return + } + if writeOnly, ok := object["writeOnly"].(bool); ok && writeOnly { + addPath(path) + return + } + if format, ok := object["format"].(string); ok && strings.EqualFold(format, "password") { + addPath(path) + return + } + if ref, ok := object["$ref"].(string); ok { + if visitingRefs[ref] { + return + } + resolved, found := resolveLocalJSONSchemaRef(root, ref) + if !found { + // The containing field is sensitive when its referenced shape + // cannot be audited locally. + addPath(path) + return + } + visitingRefs[ref] = true + collectJSONSchemaSecretPaths(resolved, root, visitingRefs, path, addPath) + delete(visitingRefs, ref) + } + if properties, ok := object["properties"].(map[string]any); ok { + for key, child := range properties { + collectJSONSchemaSecretPaths( + child, + root, + visitingRefs, + appendPath(path, key), + addPath, + ) + } + } + for _, keyword := range []string{ + "allOf", "anyOf", "oneOf", "if", "then", "else", "dependentSchemas", + } { + switch child := object[keyword].(type) { + case map[string]any: + if keyword == "dependentSchemas" { + for _, dependent := range child { + collectJSONSchemaSecretPaths( + dependent, + root, + visitingRefs, + path, + addPath, + ) + } + continue + } + collectJSONSchemaSecretPaths(child, root, visitingRefs, path, addPath) + case []any: + for _, candidate := range child { + collectJSONSchemaSecretPaths(candidate, root, visitingRefs, path, addPath) + } + } + } + for _, keyword := range []string{ + "items", "prefixItems", "contains", "unevaluatedItems", + "additionalProperties", "patternProperties", "unevaluatedProperties", + } { + child, present := object[keyword] + if present && jsonSchemaContainsSecret(child, root, make(map[string]bool)) { + // Array indexes and dynamic property names cannot be represented by + // the flat clear_secrets contract, so protect their containing value. + addPath(path) + } + } +} + +func appendPath(path []string, segment string) []string { + result := make([]string, len(path)+1) + copy(result, path) + result[len(path)] = segment + return result +} + +func collectTopLevelSchemaProperties( + node any, + root any, + visitingRefs map[string]bool, + properties map[string][]any, +) { + object, ok := node.(map[string]any) + if !ok { + return + } + if values, ok := object["properties"].(map[string]any); ok { + for key, value := range values { + properties[key] = append(properties[key], value) + } + } + if ref, ok := object["$ref"].(string); ok && !visitingRefs[ref] { + if resolved, found := resolveLocalJSONSchemaRef(root, ref); found { + visitingRefs[ref] = true + collectTopLevelSchemaProperties(resolved, root, visitingRefs, properties) + delete(visitingRefs, ref) + } + } + for _, keyword := range []string{"allOf", "anyOf", "oneOf", "if", "then", "else"} { + switch child := object[keyword].(type) { + case map[string]any: + collectTopLevelSchemaProperties(child, root, visitingRefs, properties) + case []any: + for _, candidate := range child { + collectTopLevelSchemaProperties(candidate, root, visitingRefs, properties) + } + } + } + if dependentSchemas, ok := object["dependentSchemas"].(map[string]any); ok { + for _, schema := range dependentSchemas { + collectTopLevelSchemaProperties(schema, root, visitingRefs, properties) + } + } +} + +func jsonSchemaContainsSecret(node any, root any, visitingRefs map[string]bool) bool { + switch value := node.(type) { + case map[string]any: + if writeOnly, ok := value["writeOnly"].(bool); ok && writeOnly { + return true + } + if format, ok := value["format"].(string); ok && strings.EqualFold(format, "password") { + return true + } + if ref, ok := value["$ref"].(string); ok { + if visitingRefs[ref] { + return false + } + resolved, found := resolveLocalJSONSchemaRef(root, ref) + if !found { + // An external or malformed reference cannot be audited locally. + return true + } + visitingRefs[ref] = true + secret := jsonSchemaContainsSecret(resolved, root, visitingRefs) + delete(visitingRefs, ref) + if secret { + return true + } + } + for key, child := range value { + if key == "$ref" { + continue + } + if jsonSchemaContainsSecret(child, root, visitingRefs) { + return true + } + } + case []any: + for _, child := range value { + if jsonSchemaContainsSecret(child, root, visitingRefs) { + return true + } + } + } + return false +} + +func resolveLocalJSONSchemaRef(root any, ref string) (any, bool) { + if ref == "#" { + return root, true + } + if !strings.HasPrefix(ref, "#/") { + return nil, false + } + current := root + for _, encoded := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { + segment := strings.ReplaceAll(strings.ReplaceAll(encoded, "~1", "/"), "~0", "~") + switch value := current.(type) { + case map[string]any: + var ok bool + current, ok = value[segment] + if !ok { + return nil, false + } + case []any: + index, err := strconv.Atoi(segment) + if err != nil || index < 0 || index >= len(value) { + return nil, false + } + current = value[index] + default: + return nil, false + } + } + return current, true +} + +// HasGlobalConfigSchema reports whether the manifest still declares the +// persisted config key. Callers that expose decrypted values must fail closed +// when a row has outlived its schema after a plugin upgrade. +func HasGlobalConfigSchema(manifest *pluginv1.PluginManifest, configKey string) bool { + return globalConfigSchema(manifest, configKey) != nil +} + +func globalConfigSchema(manifest *pluginv1.PluginManifest, configKey string) *pluginv1.ConfigSchema { + for _, schema := range manifest.GetGlobalConfigSchema() { + if schema != nil && schema.GetKey() == configKey { + return schema + } + } + return nil +} + +func secretFieldSet(fields []string) map[string]struct{} { + result := make(map[string]struct{}, len(fields)) + for _, field := range fields { + result[field] = struct{}{} + } + return result +} diff --git a/internal/plugins/config_secrets_test.go b/internal/plugins/config_secrets_test.go new file mode 100644 index 00000000..bc0bea15 --- /dev/null +++ b/internal/plugins/config_secrets_test.go @@ -0,0 +1,83 @@ +package plugins + +import ( + "reflect" + "testing" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" +) + +func TestGlobalConfigSecretPathsResolvesLocalRefsAndBranches(t *testing.T) { + manifest := &pluginv1.PluginManifest{GlobalConfigSchema: []*pluginv1.ConfigSchema{{ + Key: "account", + JsonSchema: `{ + "type":"object", + "properties":{ + "connection":{"$ref":"#/$defs/connection"}, + "region":{"type":"string"} + }, + "$defs":{ + "connection":{ + "type":"object", + "properties":{ + "credentials":{ + "allOf":[{"$ref":"#/$defs/credentials"}] + }, + "endpoint":{"type":"string"} + } + }, + "credentials":{ + "type":"object", + "anyOf":[{ + "properties":{ + "api_key":{"type":"string","writeOnly":true} + } + }] + } + } + }`, + }}} + + got := GlobalConfigSecretPaths(manifest, "account") + want := [][]string{{"connection", "credentials", "api_key"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("secret paths = %#v, want %#v", got, want) + } + + publicFields, secretFields := GlobalConfigFieldSets(manifest, "account") + if !reflect.DeepEqual(publicFields, []string{"region"}) || + !reflect.DeepEqual(secretFields, []string{"connection"}) { + t.Fatalf("field sets = public %#v secret %#v", publicFields, secretFields) + } +} + +func TestGlobalConfigFieldSetsIncludesSecretsDeclaredByDependentSchemas(t *testing.T) { + manifest := &pluginv1.PluginManifest{GlobalConfigSchema: []*pluginv1.ConfigSchema{{ + Key: "account", + JsonSchema: `{ + "type":"object", + "properties":{ + "mode":{"type":"string"} + }, + "dependentSchemas":{ + "mode":{ + "properties":{ + "credentials":{"type":"string","format":"password"} + } + } + } + }`, + }}} + + publicFields, secretFields := GlobalConfigFieldSets(manifest, "account") + if !reflect.DeepEqual(publicFields, []string{"mode"}) || + !reflect.DeepEqual(secretFields, []string{"credentials"}) { + t.Fatalf("field sets = public %#v secret %#v", publicFields, secretFields) + } + if got := GlobalConfigSecretPaths(manifest, "account"); !reflect.DeepEqual( + got, + [][]string{{"credentials"}}, + ) { + t.Fatalf("secret paths = %#v, want credentials", got) + } +} diff --git a/internal/plugins/runtime_config.go b/internal/plugins/runtime_config.go index abe022b2..2e0ba5eb 100644 --- a/internal/plugins/runtime_config.go +++ b/internal/plugins/runtime_config.go @@ -1,14 +1,19 @@ package plugins import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "strconv" "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/secret" ) var ( @@ -45,11 +50,25 @@ type TaskBinding struct { } type RuntimeConfigStore struct { - pool *pgxpool.Pool + pool *pgxpool.Pool + cipher *secret.Cipher } -func NewRuntimeConfigStore(pool *pgxpool.Pool) *RuntimeConfigStore { - return &RuntimeConfigStore{pool: pool} +// plugin_runtime_configs.config_value is intentionally an opaque, whole-row +// encrypted envelope. Plugins may write undeclared keys and manifest secret +// annotations can drift or be unavailable during startup backfill, so field +// classification is an Admin redaction concern, not the at-rest boundary. +// Runtime code must use RuntimeConfigStore rather than querying JSON members. +const encryptedRuntimeConfigField = "__silo_encrypted_runtime_config_v1" + +// NewRuntimeConfigStore creates the plugin config store. Production callers +// pass the server data cipher; the variadic form keeps DB-only tests concise. +func NewRuntimeConfigStore(pool *pgxpool.Pool, ciphers ...*secret.Cipher) *RuntimeConfigStore { + var cipher *secret.Cipher + if len(ciphers) > 0 { + cipher = ciphers[0] + } + return &RuntimeConfigStore{pool: pool, cipher: cipher} } func (s *RuntimeConfigStore) PutGlobalConfig( @@ -61,7 +80,7 @@ func (s *RuntimeConfigStore) PutGlobalConfig( if value == nil { value = map[string]any{} } - valueJSON, err := json.Marshal(value) + valueJSON, err := encodeRuntimeConfigValue(s.cipher, installationID, key, value) if err != nil { return fmt.Errorf("marshaling plugin runtime config: %w", err) } @@ -79,6 +98,46 @@ func (s *RuntimeConfigStore) PutGlobalConfig( return nil } +// CompareAndSwapGlobalConfig persists value only when the row still matches +// the version the caller merged. A nil expectedUpdatedAt creates the row only +// when it does not already exist. +func (s *RuntimeConfigStore) CompareAndSwapGlobalConfig( + ctx context.Context, + installationID int, + key string, + value map[string]any, + expectedUpdatedAt *time.Time, +) (bool, error) { + if value == nil { + value = map[string]any{} + } + valueJSON, err := encodeRuntimeConfigValue(s.cipher, installationID, key, value) + if err != nil { + return false, fmt.Errorf("marshaling plugin runtime config: %w", err) + } + + var tag pgconn.CommandTag + if expectedUpdatedAt == nil { + tag, err = s.pool.Exec(ctx, ` + INSERT INTO plugin_runtime_configs (plugin_installation_id, config_key, config_value) + VALUES ($1, $2, $3) + ON CONFLICT (plugin_installation_id, config_key) DO NOTHING + `, installationID, key, valueJSON) + } else { + tag, err = s.pool.Exec(ctx, ` + UPDATE plugin_runtime_configs + SET config_value = $3, updated_at = NOW() + WHERE plugin_installation_id = $1 + AND config_key = $2 + AND updated_at = $4 + `, installationID, key, valueJSON, *expectedUpdatedAt) + } + if err != nil { + return false, fmt.Errorf("compare-and-swap plugin runtime config: %w", err) + } + return tag.RowsAffected() > 0, nil +} + func (s *RuntimeConfigStore) ListGlobalConfigs(ctx context.Context, installationID int) ([]*RuntimeConfig, error) { rows, err := s.pool.Query(ctx, ` SELECT plugin_installation_id, config_key, config_value, created_at, updated_at @@ -104,11 +163,9 @@ func (s *RuntimeConfigStore) ListGlobalConfigs(ctx context.Context, installation ); err != nil { return nil, fmt.Errorf("scanning plugin runtime config: %w", err) } - config.Value = map[string]any{} - if len(valueJSON) > 0 { - if err := json.Unmarshal(valueJSON, &config.Value); err != nil { - return nil, fmt.Errorf("unmarshaling plugin runtime config: %w", err) - } + config.Value, err = decodeRuntimeConfigValue(s.cipher, config.InstallationID, config.Key, valueJSON) + if err != nil { + return nil, fmt.Errorf("decoding plugin runtime config %d/%s: %w", config.InstallationID, config.Key, err) } configs = append(configs, &config) } @@ -118,6 +175,176 @@ func (s *RuntimeConfigStore) ListGlobalConfigs(ctx context.Context, installation return configs, nil } +func runtimeConfigAAD(installationID int, key string) string { + return secret.RowAAD( + "plugin_runtime_configs", + "config_value", + strconv.Itoa(installationID)+":"+key, + ) +} + +func encodeRuntimeConfigValue( + cipher *secret.Cipher, + installationID int, + key string, + value map[string]any, +) ([]byte, error) { + if value == nil { + value = map[string]any{} + } + plaintext, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("marshaling plugin runtime config: %w", err) + } + return encodeRuntimeConfigJSON(cipher, installationID, key, plaintext) +} + +func encodeRuntimeConfigJSON( + cipher *secret.Cipher, + installationID int, + key string, + plaintext []byte, +) ([]byte, error) { + if !json.Valid(plaintext) { + return nil, errors.New("plugin runtime config is not valid JSON") + } + if cipher == nil { + return append([]byte(nil), plaintext...), nil + } + ciphertext, err := cipher.Encrypt(string(plaintext), runtimeConfigAAD(installationID, key)) + if err != nil { + return nil, fmt.Errorf("encrypting plugin runtime config: %w", err) + } + wrapped, err := json.Marshal(map[string]string{encryptedRuntimeConfigField: ciphertext}) + if err != nil { + return nil, fmt.Errorf("marshaling encrypted plugin runtime config: %w", err) + } + return wrapped, nil +} + +func decodeRuntimeConfigValue( + cipher *secret.Cipher, + installationID int, + key string, + valueJSON []byte, +) (map[string]any, error) { + if len(valueJSON) == 0 { + return map[string]any{}, nil + } + var wrapped map[string]json.RawMessage + if err := json.Unmarshal(valueJSON, &wrapped); err != nil { + return nil, fmt.Errorf("unmarshaling plugin runtime config: %w", err) + } + if rawCiphertext, ok := wrapped[encryptedRuntimeConfigField]; ok && len(wrapped) == 1 { + if cipher == nil { + return nil, errors.New("encrypted plugin runtime config requires the server data cipher") + } + var ciphertext string + if err := json.Unmarshal(rawCiphertext, &ciphertext); err != nil || !secret.IsEncrypted(ciphertext) { + return nil, errors.New("invalid encrypted plugin runtime config envelope") + } + plaintext, err := cipher.Decrypt(ciphertext, runtimeConfigAAD(installationID, key)) + if err != nil { + return nil, fmt.Errorf("decrypting plugin runtime config: %w", err) + } + valueJSON = []byte(plaintext) + } + var value map[string]any + decoder := json.NewDecoder(bytes.NewReader(valueJSON)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { + return nil, fmt.Errorf("unmarshaling plugin runtime config value: %w", err) + } + if value == nil { + value = map[string]any{} + } + return value, nil +} + +// BackfillEncryptedConfigs wraps legacy plaintext JSON objects with the same +// row-bound encryption used by PutGlobalConfig. It is idempotent and may be +// rerun after a partial failure. +func (s *RuntimeConfigStore) BackfillEncryptedConfigs(ctx context.Context) (int, error) { + if s == nil || s.pool == nil || s.cipher == nil { + return 0, nil + } + return backfillEncryptedConfigs(ctx, s.pool, s.cipher) +} + +func backfillEncryptedConfigs( + ctx context.Context, + db secret.Executor, + cipher *secret.Cipher, +) (int, error) { + rows, err := db.Query(ctx, ` + SELECT id, plugin_installation_id, config_key, config_value + FROM plugin_runtime_configs + ORDER BY id ASC + `) + if err != nil { + return 0, fmt.Errorf("listing plugin runtime configs for encryption backfill: %w", err) + } + type rowValue struct { + id int64 + installationID int + key string + valueJSON []byte + } + var pending []rowValue + for rows.Next() { + var row rowValue + if err := rows.Scan(&row.id, &row.installationID, &row.key, &row.valueJSON); err != nil { + rows.Close() + return 0, fmt.Errorf("scanning plugin runtime config for encryption backfill: %w", err) + } + var wrapped map[string]json.RawMessage + if err := json.Unmarshal(row.valueJSON, &wrapped); err != nil { + rows.Close() + return 0, fmt.Errorf("decode plugin runtime config %d for encryption backfill: %w", row.id, err) + } + if raw, ok := wrapped[encryptedRuntimeConfigField]; ok && len(wrapped) == 1 { + var ciphertext string + if json.Unmarshal(raw, &ciphertext) == nil && secret.IsEncrypted(ciphertext) { + continue + } + } + pending = append(pending, row) + } + if err := rows.Err(); err != nil { + rows.Close() + return 0, fmt.Errorf("iterating plugin runtime configs for encryption backfill: %w", err) + } + rows.Close() + + updated := 0 + for _, row := range pending { + encoded, err := encodeRuntimeConfigJSON( + cipher, + row.installationID, + row.key, + row.valueJSON, + ) + if err != nil { + return updated, fmt.Errorf("encrypt plugin runtime config %d: %w", row.id, err) + } + tag, err := db.Exec(ctx, + `UPDATE plugin_runtime_configs + SET config_value = $2, updated_at = updated_at + WHERE id = $1 AND config_value = $3::jsonb`, + row.id, + encoded, + row.valueJSON, + ) + if err != nil { + return updated, fmt.Errorf("update plugin runtime config %d encryption backfill: %w", row.id, err) + } + if tag.RowsAffected() > 0 { + updated++ + } + } + return updated, nil +} + func (s *RuntimeConfigStore) UpsertAuthBinding(ctx context.Context, binding AuthBinding) error { _, err := s.pool.Exec(ctx, ` INSERT INTO plugin_auth_bindings ( diff --git a/internal/plugins/runtime_config_encryption_test.go b/internal/plugins/runtime_config_encryption_test.go new file mode 100644 index 00000000..c074a3ee --- /dev/null +++ b/internal/plugins/runtime_config_encryption_test.go @@ -0,0 +1,252 @@ +package plugins + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/Silo-Server/silo-server/internal/secret" +) + +type runtimeConfigBackfillRows struct { + id int64 + installationID int + key string + valueJSON []byte + yielded bool +} + +func (r *runtimeConfigBackfillRows) Next() bool { + if r.yielded { + return false + } + r.yielded = true + return true +} + +func (r *runtimeConfigBackfillRows) Scan(dest ...any) error { + if len(dest) != 4 { + return fmt.Errorf("scan destinations = %d, want 4", len(dest)) + } + *dest[0].(*int64) = r.id + *dest[1].(*int) = r.installationID + *dest[2].(*string) = r.key + *dest[3].(*[]byte) = append([]byte(nil), r.valueJSON...) + return nil +} + +func (r *runtimeConfigBackfillRows) Close() {} +func (r *runtimeConfigBackfillRows) Err() error { return nil } +func (r *runtimeConfigBackfillRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} } +func (r *runtimeConfigBackfillRows) FieldDescriptions() []pgconn.FieldDescription { return nil } +func (r *runtimeConfigBackfillRows) Values() ([]any, error) { return nil, nil } +func (r *runtimeConfigBackfillRows) RawValues() [][]byte { return nil } +func (r *runtimeConfigBackfillRows) Conn() *pgx.Conn { return nil } + +type runtimeConfigBackfillExec struct { + row runtimeConfigBackfillRows + updateTag pgconn.CommandTag + updateSQL string + updateArgs []any +} + +func (e *runtimeConfigBackfillExec) Query( + _ context.Context, + _ string, + _ ...any, +) (pgx.Rows, error) { + row := e.row + return &row, nil +} + +func (e *runtimeConfigBackfillExec) Exec( + _ context.Context, + sql string, + args ...any, +) (pgconn.CommandTag, error) { + e.updateSQL = sql + e.updateArgs = append([]any(nil), args...) + return e.updateTag, nil +} + +func TestRuntimeConfigEncryptionRoundTrip(t *testing.T) { + cipher, err := secret.New(bytes.Repeat([]byte("k"), secret.MinMasterKeyLen)) + if err != nil { + t.Fatalf("secret.New: %v", err) + } + value := map[string]any{"api_key": "clawrouter-e2e-secret", "enabled": true} + + encoded, err := encodeRuntimeConfigValue(cipher, 42, "account", value) + if err != nil { + t.Fatalf("encodeRuntimeConfigValue: %v", err) + } + if bytes.Contains(encoded, []byte("clawrouter-e2e-secret")) { + t.Fatalf("encoded config contains plaintext secret: %s", encoded) + } + decoded, err := decodeRuntimeConfigValue(cipher, 42, "account", encoded) + if err != nil { + t.Fatalf("decodeRuntimeConfigValue: %v", err) + } + if decoded["api_key"] != "clawrouter-e2e-secret" || decoded["enabled"] != true { + t.Fatalf("decoded = %#v", decoded) + } +} + +func TestRuntimeConfigEncryptionBindsInstallationAndKey(t *testing.T) { + cipher, err := secret.New(bytes.Repeat([]byte("k"), secret.MinMasterKeyLen)) + if err != nil { + t.Fatalf("secret.New: %v", err) + } + encoded, err := encodeRuntimeConfigValue(cipher, 42, "account", map[string]any{ + "api_key": "clawrouter-e2e-secret", + }) + if err != nil { + t.Fatalf("encodeRuntimeConfigValue: %v", err) + } + if _, err := decodeRuntimeConfigValue(cipher, 43, "account", encoded); err == nil { + t.Fatal("decode with a different installation id succeeded") + } + if _, err := decodeRuntimeConfigValue(cipher, 42, "other", encoded); err == nil { + t.Fatal("decode with a different config key succeeded") + } +} + +func TestRuntimeConfigLegacyPlaintextRemainsReadable(t *testing.T) { + decoded, err := decodeRuntimeConfigValue(nil, 1, "account", []byte(`{"api_key":"clawrouter-e2e-secret"}`)) + if err != nil { + t.Fatalf("decodeRuntimeConfigValue: %v", err) + } + if decoded["api_key"] != "clawrouter-e2e-secret" { + t.Fatalf("decoded = %#v", decoded) + } +} + +func TestRuntimeConfigUnrelatedEditPreservesLargeInteger(t *testing.T) { + cipher, err := secret.New(bytes.Repeat([]byte("k"), secret.MinMasterKeyLen)) + if err != nil { + t.Fatalf("secret.New: %v", err) + } + const largeInteger = "9007199254740993" + encoded, err := encodeRuntimeConfigJSON( + cipher, + 42, + "account", + []byte(`{"cursor":`+largeInteger+`,"display_name":"old"}`), + ) + if err != nil { + t.Fatalf("encodeRuntimeConfigJSON: %v", err) + } + + decoded, err := decodeRuntimeConfigValue(cipher, 42, "account", encoded) + if err != nil { + t.Fatalf("decodeRuntimeConfigValue: %v", err) + } + cursor, ok := decoded["cursor"].(json.Number) + if !ok || cursor.String() != largeInteger { + t.Fatalf("decoded cursor = %#v, want json.Number(%s)", decoded["cursor"], largeInteger) + } + decoded["display_name"] = "new" + + reencoded, err := encodeRuntimeConfigValue(cipher, 42, "account", decoded) + if err != nil { + t.Fatalf("encodeRuntimeConfigValue: %v", err) + } + var envelope map[string]string + if err := json.Unmarshal(reencoded, &envelope); err != nil { + t.Fatalf("unmarshal encrypted envelope: %v", err) + } + plaintext, err := cipher.Decrypt( + envelope[encryptedRuntimeConfigField], + runtimeConfigAAD(42, "account"), + ) + if err != nil { + t.Fatalf("decrypt edited value: %v", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal([]byte(plaintext), &raw); err != nil { + t.Fatalf("unmarshal edited plaintext: %v", err) + } + if string(raw["cursor"]) != largeInteger { + t.Fatalf("edited cursor = %s, want %s", raw["cursor"], largeInteger) + } + if string(raw["display_name"]) != `"new"` { + t.Fatalf("edited display_name = %s, want new", raw["display_name"]) + } +} + +func TestRuntimeConfigEncryptionBackfillPreservesLargeInteger(t *testing.T) { + cipher, err := secret.New(bytes.Repeat([]byte("k"), secret.MinMasterKeyLen)) + if err != nil { + t.Fatalf("secret.New: %v", err) + } + legacy := []byte(`{"cursor":9007199254740993}`) + db := &runtimeConfigBackfillExec{ + row: runtimeConfigBackfillRows{ + id: 9, + installationID: 42, + key: "account", + valueJSON: legacy, + }, + updateTag: pgconn.NewCommandTag("UPDATE 1"), + } + + updated, err := backfillEncryptedConfigs(context.Background(), db, cipher) + if err != nil { + t.Fatalf("backfillEncryptedConfigs: %v", err) + } + if updated != 1 { + t.Fatalf("updated = %d, want 1", updated) + } + encoded := db.updateArgs[1].([]byte) + var envelope map[string]string + if err := json.Unmarshal(encoded, &envelope); err != nil { + t.Fatalf("unmarshal encrypted envelope: %v", err) + } + plaintext, err := cipher.Decrypt( + envelope[encryptedRuntimeConfigField], + runtimeConfigAAD(42, "account"), + ) + if err != nil { + t.Fatalf("decrypt backfilled value: %v", err) + } + if plaintext != string(legacy) { + t.Fatalf("backfilled plaintext = %s, want %s", plaintext, legacy) + } +} + +func TestRuntimeConfigEncryptionBackfillSkipsConcurrentWrite(t *testing.T) { + cipher, err := secret.New(bytes.Repeat([]byte("k"), secret.MinMasterKeyLen)) + if err != nil { + t.Fatalf("secret.New: %v", err) + } + legacy := []byte(`{"region":"original"}`) + db := &runtimeConfigBackfillExec{ + row: runtimeConfigBackfillRows{ + id: 9, + installationID: 42, + key: "account", + valueJSON: legacy, + }, + updateTag: pgconn.NewCommandTag("UPDATE 0"), + } + + updated, err := backfillEncryptedConfigs(context.Background(), db, cipher) + if err != nil { + t.Fatalf("backfillEncryptedConfigs: %v", err) + } + if updated != 0 { + t.Fatalf("updated = %d, want 0 after concurrent replacement", updated) + } + if !strings.Contains(db.updateSQL, "config_value = $3::jsonb") { + t.Fatalf("query predicate missing: %s", db.updateSQL) + } + if len(db.updateArgs) != 3 || !bytes.Equal(db.updateArgs[2].([]byte), legacy) { + t.Fatal("third query argument mismatch") + } +} diff --git a/internal/plugins/service.go b/internal/plugins/service.go index 93ddfe11..5b4c5faa 100644 --- a/internal/plugins/service.go +++ b/internal/plugins/service.go @@ -15,6 +15,7 @@ import ( "strings" "sync" "sync/atomic" + "time" "golang.org/x/sync/singleflight" "google.golang.org/protobuf/proto" @@ -58,6 +59,13 @@ type serviceInstallationStore interface { type serviceConfigStore interface { ListGlobalConfigs(ctx context.Context, installationID int) ([]*RuntimeConfig, error) PutGlobalConfig(ctx context.Context, installationID int, key string, value map[string]any) error + CompareAndSwapGlobalConfig( + ctx context.Context, + installationID int, + key string, + value map[string]any, + expectedUpdatedAt *time.Time, + ) (bool, error) } type Service struct { diff --git a/internal/plugins/service_admin_config.go b/internal/plugins/service_admin_config.go index c47265f7..d9b2ee4b 100644 --- a/internal/plugins/service_admin_config.go +++ b/internal/plugins/service_admin_config.go @@ -2,10 +2,13 @@ package plugins import ( "context" + "encoding/json" "errors" "fmt" "strings" + "time" + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" "github.com/Silo-Server/silo-server/internal/pluginhost" ) @@ -30,6 +33,19 @@ func (s *Service) SetGlobalConfig( installationID int, key string, value map[string]any, +) error { + return s.SetGlobalConfigWithClears(ctx, installationID, key, value, nil) +} + +// SetGlobalConfigWithClears saves one config entry while preserving redacted +// secret fields the browser left blank. A secret is removed only when its key +// appears in clearSecrets, making credential deletion an explicit action. +func (s *Service) SetGlobalConfigWithClears( + ctx context.Context, + installationID int, + key string, + value map[string]any, + clearSecrets []string, ) error { key = strings.TrimSpace(key) if key == "" { @@ -47,12 +63,50 @@ func (s *Service) SetGlobalConfig( if value == nil { value = map[string]any{} } - if err := ValidateGlobalConfigValue(manifest, key, value); err != nil { + secretFields := GlobalConfigSecretFields(manifest, key) + secretPaths := GlobalConfigSecretPaths(manifest, key) + clearSet, err := validatedSecretClearSet(key, secretFields, clearSecrets) + if err != nil { return &ConfigValidationError{Message: err.Error(), Cause: err} } + const maxConfigSaveAttempts = 5 + saved := false + for attempt := 0; attempt < maxConfigSaveAttempts; attempt++ { + merged, expectedUpdatedAt, err := s.mergeStoredConfig( + ctx, + installationID, + key, + value, + secretPaths, + ) + if err != nil { + return err + } + for field := range clearSet { + delete(merged, field) + } + projection := globalConfigValidationProjection(manifest, key, merged, value) + if err := ValidateGlobalConfigValue(manifest, key, projection); err != nil { + return &ConfigValidationError{Message: err.Error(), Cause: err} + } - if err := s.configs.PutGlobalConfig(ctx, installationID, key, value); err != nil { - return fmt.Errorf("persist plugin config: %w", err) + swapped, err := s.configs.CompareAndSwapGlobalConfig( + ctx, + installationID, + key, + merged, + expectedUpdatedAt, + ) + if err != nil { + return fmt.Errorf("persist plugin config: %w", err) + } + if swapped { + saved = true + break + } + } + if !saved { + return fmt.Errorf("persist plugin config: concurrent updates did not settle") } if s.host != nil { @@ -62,3 +116,242 @@ func (s *Service) SetGlobalConfig( } return nil } + +func validatedSecretClearSet( + configKey string, + secretFields []string, + clearSecrets []string, +) (map[string]struct{}, error) { + clearSet := secretFieldSet(clearSecrets) + allowedSecrets := secretFieldSet(secretFields) + for field := range clearSet { + if _, ok := allowedSecrets[field]; !ok { + return nil, fmt.Errorf("%s is not a secret field in config %s", field, configKey) + } + } + return clearSet, nil +} + +func (s *Service) preserveStoredSecrets( + ctx context.Context, + installationID int, + key string, + value map[string]any, + secretPaths [][]string, +) (map[string]any, error) { + merged, _, err := s.mergeStoredConfig(ctx, installationID, key, value, secretPaths) + return merged, err +} + +func (s *Service) mergeStoredConfig( + ctx context.Context, + installationID int, + key string, + value map[string]any, + secretPaths [][]string, +) (map[string]any, *time.Time, error) { + if s.configs == nil { + if len(secretPaths) > 0 { + return nil, nil, fmt.Errorf("plugin config store not configured") + } + merged := make(map[string]any, len(value)) + for field, incoming := range value { + merged[field] = cloneConfigValue(incoming) + } + return merged, nil, nil + } + configs, err := s.configs.ListGlobalConfigs(ctx, installationID) + if err != nil { + return nil, nil, fmt.Errorf("load existing plugin config: %w", err) + } + var existing map[string]any + var expectedUpdatedAt *time.Time + for _, config := range configs { + if config != nil && config.Key == key { + existing = config.Value + updatedAt := config.UpdatedAt + expectedUpdatedAt = &updatedAt + break + } + } + merged := make(map[string]any, len(existing)+len(value)) + for field, saved := range existing { + merged[field] = cloneConfigValue(saved) + } + for field, incoming := range value { + merged[field] = cloneConfigValue(incoming) + } + if len(secretPaths) == 0 { + return merged, expectedUpdatedAt, nil + } + pathsByField := make(map[string][][]string) + for _, path := range secretPaths { + if len(path) == 0 { + continue + } + pathsByField[path[0]] = append(pathsByField[path[0]], path[1:]) + } + for field, nestedSecretPaths := range pathsByField { + incoming, present := value[field] + saved, savedPresent := existing[field] + if !present { + continue + } + incomingObject, incomingIsObject := incoming.(map[string]any) + savedObject, savedIsObject := saved.(map[string]any) + if savedPresent && incomingIsObject && savedIsObject { + merged[field] = mergeConfigObjects(savedObject, incomingObject, nestedSecretPaths) + continue + } + incomingString, isString := incoming.(string) + if !isString || strings.TrimSpace(incomingString) != "" { + continue + } + if savedPresent { + merged[field] = cloneConfigValue(saved) + } + } + return merged, expectedUpdatedAt, nil +} + +func globalConfigValidationProjection( + manifest *pluginv1.PluginManifest, + key string, + merged map[string]any, + submitted map[string]any, +) map[string]any { + projection := make(map[string]any) + schema := globalConfigSchema(manifest, key) + if schema != nil { + var document any + if json.Unmarshal([]byte(schema.GetJsonSchema()), &document) == nil { + projection = projectDeclaredConfigObject( + merged, + []any{document}, + document, + ) + } + } + overlaySubmittedConfig(projection, submitted, merged) + return projection +} + +func projectDeclaredConfigObject( + value map[string]any, + schemaNodes []any, + root any, +) map[string]any { + properties := make(map[string][]any) + for _, node := range schemaNodes { + collectTopLevelSchemaProperties(node, root, make(map[string]bool), properties) + } + projected := make(map[string]any, len(properties)) + for field, fieldSchemas := range properties { + saved, present := value[field] + if !present { + continue + } + savedObject, isObject := saved.(map[string]any) + if isObject { + projected[field] = projectDeclaredConfigObject(savedObject, fieldSchemas, root) + continue + } + projected[field] = cloneConfigValue(saved) + } + return projected +} + +func overlaySubmittedConfig( + projection map[string]any, + submitted map[string]any, + merged map[string]any, +) { + for field, submittedValue := range submitted { + mergedValue, present := merged[field] + if !present { + delete(projection, field) + continue + } + submittedObject, submittedIsObject := submittedValue.(map[string]any) + mergedObject, mergedIsObject := mergedValue.(map[string]any) + if submittedIsObject && mergedIsObject { + projectedObject, _ := projection[field].(map[string]any) + if projectedObject == nil { + projectedObject = make(map[string]any) + } + overlaySubmittedConfig(projectedObject, submittedObject, mergedObject) + projection[field] = projectedObject + continue + } + projection[field] = cloneConfigValue(mergedValue) + } +} + +func mergeConfigObjects( + saved map[string]any, + incoming map[string]any, + secretPaths [][]string, +) map[string]any { + merged := make(map[string]any, len(saved)+len(incoming)) + for key, value := range saved { + merged[key] = cloneConfigValue(value) + } + for key, value := range incoming { + nestedSecretPaths, wholeValueSecret := childSecretPaths(secretPaths, key) + if text, isString := value.(string); wholeValueSecret && + isString && strings.TrimSpace(text) == "" { + if savedValue, present := saved[key]; present { + merged[key] = cloneConfigValue(savedValue) + continue + } + } + incomingObject, incomingIsObject := value.(map[string]any) + savedObject, savedIsObject := saved[key].(map[string]any) + if incomingIsObject && savedIsObject { + if wholeValueSecret { + nestedSecretPaths = append(nestedSecretPaths, nil) + } + merged[key] = mergeConfigObjects(savedObject, incomingObject, nestedSecretPaths) + continue + } + merged[key] = cloneConfigValue(value) + } + return merged +} + +func childSecretPaths(paths [][]string, key string) (nested [][]string, wholeValue bool) { + for _, path := range paths { + if len(path) == 0 { + wholeValue = true + continue + } + if path[0] != key { + continue + } + if len(path) == 1 { + wholeValue = true + continue + } + nested = append(nested, path[1:]) + } + return nested, wholeValue +} + +func cloneConfigValue(value any) any { + switch typed := value.(type) { + case map[string]any: + cloned := make(map[string]any, len(typed)) + for key, entry := range typed { + cloned[key] = cloneConfigValue(entry) + } + return cloned + case []any: + cloned := make([]any, len(typed)) + for index, entry := range typed { + cloned[index] = cloneConfigValue(entry) + } + return cloned + default: + return value + } +} diff --git a/internal/plugins/service_connection.go b/internal/plugins/service_connection.go index 8dbbaa8b..600996e3 100644 --- a/internal/plugins/service_connection.go +++ b/internal/plugins/service_connection.go @@ -87,6 +87,19 @@ func (s *Service) TestGlobalConfig( installationID int, key string, value map[string]any, +) error { + return s.TestGlobalConfigWithClears(ctx, installationID, key, value, nil) +} + +// TestGlobalConfigWithClears tests the exact prospective configuration, +// including explicit removals of saved secrets. This keeps a successful probe +// from describing credentials the operator has already staged for deletion. +func (s *Service) TestGlobalConfigWithClears( + ctx context.Context, + installationID int, + key string, + value map[string]any, + clearSecrets []string, ) error { if strings.TrimSpace(key) == "" { return &ConnectionTestError{Message: "Config key is required"} @@ -103,7 +116,28 @@ func (s *Service) TestGlobalConfig( if value == nil { value = map[string]any{} } - if err := ValidateGlobalConfigValue(manifest, key, value); err != nil { + submitted := value + secretFields := GlobalConfigSecretFields(manifest, key) + secretPaths := GlobalConfigSecretPaths(manifest, key) + clearSet, err := validatedSecretClearSet(key, secretFields, clearSecrets) + if err != nil { + return &ConnectionTestError{Message: err.Error(), Cause: err} + } + value, err = s.preserveStoredSecrets( + ctx, + installationID, + key, + value, + secretPaths, + ) + if err != nil { + return err + } + for field := range clearSet { + delete(value, field) + } + projection := globalConfigValidationProjection(manifest, key, value, submitted) + if err := ValidateGlobalConfigValue(manifest, key, projection); err != nil { return &ConnectionTestError{ Message: err.Error(), Cause: err, diff --git a/internal/plugins/service_connection_test.go b/internal/plugins/service_connection_test.go index 192020ae..b7878b96 100644 --- a/internal/plugins/service_connection_test.go +++ b/internal/plugins/service_connection_test.go @@ -3,8 +3,10 @@ package plugins import ( "context" "errors" + "reflect" "strings" "testing" + "time" pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" "google.golang.org/protobuf/types/known/structpb" @@ -14,6 +16,9 @@ type fakeServiceConfigStore struct { configsByInstallation map[int][]*RuntimeConfig puts []putGlobalConfigCall putErr error + casFailures int + casCalls int + concurrentUpdates map[string]any } type putGlobalConfigCall struct { @@ -22,6 +27,437 @@ type putGlobalConfigCall struct { value map[string]any } +func TestPreserveStoredSecretsKeepsRedactedBlankAndAcceptsReplacement(t *testing.T) { + store := &fakeServiceConfigStore{configsByInstallation: map[int][]*RuntimeConfig{ + 7: { + {InstallationID: 7, Key: "account", Value: map[string]any{"api_key": "saved", "region": "old"}}, + }, + }} + service := &Service{configs: store} + + merged, err := service.preserveStoredSecrets( + context.Background(), + 7, + "account", + map[string]any{"api_key": "", "region": "new"}, + [][]string{{"api_key"}}, + ) + if err != nil { + t.Fatalf("preserveStoredSecrets: %v", err) + } + if merged["api_key"] != "saved" || merged["region"] != "new" { + t.Fatalf("merged = %#v", merged) + } + + replaced, err := service.preserveStoredSecrets( + context.Background(), + 7, + "account", + map[string]any{"api_key": "clawrouter-e2e-secret"}, + [][]string{{"api_key"}}, + ) + if err != nil { + t.Fatalf("preserveStoredSecrets replacement: %v", err) + } + if replaced["api_key"] != "clawrouter-e2e-secret" { + t.Fatalf("replacement = %#v", replaced) + } +} + +func TestPreserveStoredSecretsMergesNestedObjectsWithoutMutatingInputs(t *testing.T) { + savedConnection := map[string]any{ + "credentials": map[string]any{ + "api_key": "clawrouter-e2e-secret", + "labels": []any{"one", "two"}, + }, + "endpoint": "https://old.example.invalid", + } + incomingConnection := map[string]any{ + "credentials": map[string]any{ + "account": "updated", + "api_key": " ", + }, + "endpoint": "", + } + wantSaved := cloneConfigValue(savedConnection) + wantIncoming := cloneConfigValue(incomingConnection) + store := &fakeServiceConfigStore{configsByInstallation: map[int][]*RuntimeConfig{ + 7: {{ + InstallationID: 7, + Key: "account", + Value: map[string]any{"connection": savedConnection}, + }}, + }} + service := &Service{configs: store} + + merged, err := service.preserveStoredSecrets( + context.Background(), + 7, + "account", + map[string]any{"connection": incomingConnection}, + [][]string{{"connection", "credentials", "api_key"}}, + ) + if err != nil { + t.Fatalf("preserveStoredSecrets: %v", err) + } + + connection, ok := merged["connection"].(map[string]any) + if !ok { + t.Fatalf("merged connection = %#v, want object", merged["connection"]) + } + credentials, ok := connection["credentials"].(map[string]any) + if !ok { + t.Fatalf("merged credentials = %#v, want object", connection["credentials"]) + } + if credentials["api_key"] != "clawrouter-e2e-secret" || credentials["account"] != "updated" { + t.Fatalf("merged credentials = %#v", credentials) + } + if connection["endpoint"] != "" { + t.Fatalf("merged endpoint = %#v, want blank non-secret value", connection["endpoint"]) + } + credentials["api_key"] = "mutated" + credentials["labels"].([]any)[0] = "mutated" + connection["endpoint"] = "https://mutated.example.invalid" + if !reflect.DeepEqual(savedConnection, wantSaved) { + t.Fatalf("saved input mutated: got %#v want %#v", savedConnection, wantSaved) + } + if !reflect.DeepEqual(incomingConnection, wantIncoming) { + t.Fatalf("incoming input mutated: got %#v want %#v", incomingConnection, wantIncoming) + } + + replacement, err := service.preserveStoredSecrets( + context.Background(), + 7, + "account", + map[string]any{"connection": map[string]any{ + "credentials": map[string]any{"api_key": "test-auth-token"}, + }}, + [][]string{{"connection", "credentials", "api_key"}}, + ) + if err != nil { + t.Fatalf("preserveStoredSecrets replacement: %v", err) + } + replacementConnection := replacement["connection"].(map[string]any) + replacementCredentials := replacementConnection["credentials"].(map[string]any) + if replacementCredentials["api_key"] != "test-auth-token" { + t.Fatalf("replacement credentials = %#v", replacementCredentials) + } +} + +func TestSetGlobalConfigWithClearsValidatesTheClearedResult(t *testing.T) { + for _, tc := range []struct { + name string + required bool + wantError bool + }{ + {name: "required field is rejected", required: true, wantError: true}, + {name: "optional field is removed", required: false, wantError: false}, + } { + t.Run(tc.name, func(t *testing.T) { + manifest := connectionTestManifest(t, "silo.metadb", "0.0.36") + if tc.required { + manifest.GlobalConfigSchema[0].JsonSchema = `{"type":"object","properties":{"api_key":{"type":"string","format":"password"}},"required":["api_key"],"additionalProperties":false}` + } else { + manifest.GlobalConfigSchema[0].JsonSchema = `{"type":"object","properties":{"api_key":{"type":"string","format":"password"}},"additionalProperties":false}` + } + installPath := writeInstalledPluginManifest(t, manifest) + store := &fakeServiceConfigStore{configsByInstallation: map[int][]*RuntimeConfig{ + 7: {{ + InstallationID: 7, + Key: "connection", + Value: map[string]any{ + "api_key": "clawrouter-e2e-secret", + "plugin_owned": "retained", + }, + }}, + }} + service := &Service{ + installations: newFakeServiceInstallationStore(&Installation{ + ID: 7, + PluginID: manifest.GetPluginId(), + Version: manifest.GetVersion(), + InstallPath: installPath, + Enabled: true, + }), + configs: store, + } + + err := service.SetGlobalConfigWithClears( + context.Background(), + 7, + "connection", + map[string]any{}, + []string{"api_key"}, + ) + if tc.wantError { + var validationErr *ConfigValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error = %v, want ConfigValidationError", err) + } + if len(store.puts) != 0 { + t.Fatalf("persisted invalid cleared config: %#v", store.puts) + } + return + } + if err != nil { + t.Fatal(err) + } + if len(store.puts) != 1 { + t.Fatalf("put calls = %d, want 1", len(store.puts)) + } + if _, present := store.puts[0].value["api_key"]; present { + t.Fatalf("cleared field remained in config: %#v", store.puts[0].value) + } + if store.puts[0].value["plugin_owned"] != "retained" { + t.Fatalf("plugin-owned field was not retained: %#v", store.puts[0].value) + } + }) + } +} + +func TestSetGlobalConfigWithClearsPreservesAndExplicitlyClearsNestedSecretObject(t *testing.T) { + manifest := connectionTestManifest(t, "silo.metadb", "0.0.36") + manifest.GlobalConfigSchema[0].JsonSchema = `{ + "type":"object", + "properties":{ + "connection":{ + "type":"object", + "properties":{ + "api_key":{"type":"string","format":"password"}, + "endpoint":{"type":"string"} + }, + "additionalProperties":false + }, + "region":{"type":"string"} + }, + "additionalProperties":false + }` + manifest.GlobalConfigSchema[0].AdminForm = nil + installPath := writeInstalledPluginManifest(t, manifest) + store := &fakeServiceConfigStore{configsByInstallation: map[int][]*RuntimeConfig{ + 7: {{ + InstallationID: 7, + Key: "connection", + Value: map[string]any{ + "connection": map[string]any{ + "api_key": "clawrouter-e2e-secret", + "endpoint": "https://old.example.invalid", + }, + "region": "old", + }, + }}, + }} + service := &Service{ + installations: newFakeServiceInstallationStore(&Installation{ + ID: 7, + PluginID: manifest.GetPluginId(), + Version: manifest.GetVersion(), + InstallPath: installPath, + Enabled: true, + }), + configs: store, + } + + err := service.SetGlobalConfigWithClears( + context.Background(), + 7, + "connection", + map[string]any{ + "connection": map[string]any{ + "api_key": "", + "endpoint": "", + }, + "region": "new", + }, + nil, + ) + if err != nil { + t.Fatal(err) + } + if len(store.puts) != 1 { + t.Fatalf("put calls = %d, want 1", len(store.puts)) + } + connection := store.puts[0].value["connection"].(map[string]any) + if connection["api_key"] != "clawrouter-e2e-secret" || + connection["endpoint"] != "" { + t.Fatalf("persisted connection = %#v", connection) + } + + err = service.SetGlobalConfigWithClears( + context.Background(), + 7, + "connection", + map[string]any{"region": "new"}, + []string{"connection"}, + ) + if err != nil { + t.Fatal(err) + } + if len(store.puts) != 2 { + t.Fatalf("put calls = %d, want 2", len(store.puts)) + } + if _, present := store.puts[1].value["connection"]; present { + t.Fatalf("explicitly cleared connection remained: %#v", store.puts[1].value) + } +} + +func TestSetGlobalConfigPreservesOmittedPluginOwnedFields(t *testing.T) { + manifest := connectionTestManifest(t, "silo.metadb", "0.0.36") + manifest.GlobalConfigSchema[0].JsonSchema = `{ + "type":"object", + "properties":{ + "display_name":{"type":"string"}, + "settings":{ + "type":"object", + "properties":{"endpoint":{"type":"string"}}, + "additionalProperties":false + } + }, + "additionalProperties":false + }` + manifest.GlobalConfigSchema[0].AdminForm = nil + installPath := writeInstalledPluginManifest(t, manifest) + pluginState := map[string]any{ + "cursor": "plugin-managed-cursor", + "options": []any{"one", "two"}, + } + store := &fakeServiceConfigStore{configsByInstallation: map[int][]*RuntimeConfig{ + 7: {{ + InstallationID: 7, + Key: "connection", + Value: map[string]any{ + "display_name": "old", + "plugin_state": pluginState, + "settings": map[string]any{ + "endpoint": "https://example.invalid", + "plugin_owned": "retained", + }, + }, + }}, + }} + service := &Service{ + installations: newFakeServiceInstallationStore(&Installation{ + ID: 7, + PluginID: manifest.GetPluginId(), + Version: manifest.GetVersion(), + InstallPath: installPath, + Enabled: true, + }), + configs: store, + } + + err := service.SetGlobalConfig( + context.Background(), + 7, + "connection", + map[string]any{"display_name": "new"}, + ) + if err != nil { + t.Fatal(err) + } + if len(store.puts) != 1 { + t.Fatalf("put calls = %d, want 1", len(store.puts)) + } + if store.puts[0].value["display_name"] != "new" { + t.Fatalf("display_name = %#v, want new", store.puts[0].value["display_name"]) + } + if !reflect.DeepEqual(store.puts[0].value["plugin_state"], pluginState) { + t.Fatalf( + "plugin_state = %#v, want %#v", + store.puts[0].value["plugin_state"], + pluginState, + ) + } + wantSettings := map[string]any{ + "endpoint": "https://example.invalid", + "plugin_owned": "retained", + } + if !reflect.DeepEqual(store.puts[0].value["settings"], wantSettings) { + t.Fatalf( + "settings = %#v, want %#v", + store.puts[0].value["settings"], + wantSettings, + ) + } + + err = service.SetGlobalConfig( + context.Background(), + 7, + "connection", + map[string]any{"unexpected": "submitted"}, + ) + var validationErr *ConfigValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("submitted opaque field error = %v, want ConfigValidationError", err) + } + if len(store.puts) != 1 { + t.Fatalf("invalid submitted field persisted: %#v", store.puts) + } +} + +func TestSetGlobalConfigRetriesConcurrentMerge(t *testing.T) { + manifest := connectionTestManifest(t, "silo.metadb", "0.0.36") + manifest.GlobalConfigSchema[0].JsonSchema = `{ + "type":"object", + "properties":{"display_name":{"type":"string"}} + }` + manifest.GlobalConfigSchema[0].AdminForm = nil + installPath := writeInstalledPluginManifest(t, manifest) + store := &fakeServiceConfigStore{ + configsByInstallation: map[int][]*RuntimeConfig{ + 7: {{ + InstallationID: 7, + Key: "connection", + Value: map[string]any{ + "display_name": "old", + "plugin_state": map[string]any{"cursor": "old"}, + }, + }}, + }, + casFailures: 1, + concurrentUpdates: map[string]any{ + "plugin_state": map[string]any{"cursor": "concurrent"}, + }, + } + service := &Service{ + installations: newFakeServiceInstallationStore(&Installation{ + ID: 7, + PluginID: manifest.GetPluginId(), + Version: manifest.GetVersion(), + InstallPath: installPath, + Enabled: true, + }), + configs: store, + } + + err := service.SetGlobalConfig( + context.Background(), + 7, + "connection", + map[string]any{"display_name": "new"}, + ) + if err != nil { + t.Fatal(err) + } + if store.casCalls != 2 { + t.Fatalf("CAS calls = %d, want 2", store.casCalls) + } + if len(store.puts) != 1 { + t.Fatalf("successful writes = %d, want 1", len(store.puts)) + } + if store.puts[0].value["display_name"] != "new" { + t.Fatalf("display_name = %#v, want new", store.puts[0].value["display_name"]) + } + wantState := map[string]any{"cursor": "concurrent"} + if !reflect.DeepEqual(store.puts[0].value["plugin_state"], wantState) { + t.Fatalf( + "plugin_state = %#v, want concurrent update %#v", + store.puts[0].value["plugin_state"], + wantState, + ) + } +} + func (f *fakeServiceConfigStore) ListGlobalConfigs( _ context.Context, installationID int, @@ -53,6 +489,59 @@ func (f *fakeServiceConfigStore) PutGlobalConfig( return f.putErr } +func (f *fakeServiceConfigStore) CompareAndSwapGlobalConfig( + _ context.Context, + installationID int, + key string, + value map[string]any, + expectedUpdatedAt *time.Time, +) (bool, error) { + f.casCalls++ + if f.putErr != nil { + return false, f.putErr + } + configs := f.configsByInstallation[installationID] + var existing *RuntimeConfig + for _, config := range configs { + if config != nil && config.Key == key { + existing = config + break + } + } + if f.casFailures > 0 { + f.casFailures-- + if existing != nil { + for field, update := range f.concurrentUpdates { + existing.Value[field] = cloneConfigValue(update) + } + existing.UpdatedAt = existing.UpdatedAt.Add(time.Second) + } + return false, nil + } + switch { + case existing == nil && expectedUpdatedAt != nil: + return false, nil + case existing != nil && (expectedUpdatedAt == nil || + !existing.UpdatedAt.Equal(*expectedUpdatedAt)): + return false, nil + } + if existing == nil { + existing = &RuntimeConfig{ + InstallationID: installationID, + Key: key, + } + f.configsByInstallation[installationID] = append(configs, existing) + } + existing.Value = cloneConfigValue(value).(map[string]any) + existing.UpdatedAt = existing.UpdatedAt.Add(time.Second) + f.puts = append(f.puts, putGlobalConfigCall{ + installationID: installationID, + key: key, + value: cloneConfigValue(value).(map[string]any), + }) + return true, nil +} + func TestServiceTestGlobalConfigUsesMergedDraftAndStopsTemporaryInstance(t *testing.T) { originalProbe := runPluginConnectionCheck t.Cleanup(func() { @@ -76,6 +565,7 @@ func TestServiceTestGlobalConfigUsesMergedDraftAndStopsTemporaryInstance(t *test } manifest := connectionTestManifest(t, "silo.metadb", "0.0.36") + manifest.GlobalConfigSchema[0].JsonSchema = `{"type":"object","properties":{"api_key":{"type":"string","format":"password"}},"required":["api_key"],"additionalProperties":false}` installPath := writeInstalledPluginManifest(t, manifest) host := &fakeServiceHost{ startResult: &fakePluginClient{manifest: manifest}, @@ -148,6 +638,21 @@ func TestServiceTestGlobalConfigUsesMergedDraftAndStopsTemporaryInstance(t *test if got := valuesByKey["secondary"]["enabled"]; got != true { t.Fatalf("secondary enabled = %#v, want true", got) } + + err := service.TestGlobalConfigWithClears( + context.Background(), + 42, + "connection", + map[string]any{}, + []string{"api_key"}, + ) + var connectionErr *ConnectionTestError + if !errors.As(err, &connectionErr) { + t.Fatalf("cleared required secret error = %v, want ConnectionTestError", err) + } + if probeCalls != 1 || len(host.started) != 1 { + t.Fatalf("invalid cleared config reached probe: probes=%d starts=%d", probeCalls, len(host.started)) + } } func TestRunPluginConnectionCheckSkipsMovieProbeForAudiobookOnlyProvider(t *testing.T) { diff --git a/internal/ratelimit/config.go b/internal/ratelimit/config.go index a1b65029..23fe65f3 100644 --- a/internal/ratelimit/config.go +++ b/internal/ratelimit/config.go @@ -15,6 +15,10 @@ type SettingsStore interface { GetAll(ctx context.Context) (map[string]string, error) } +type settingsBatchWriter interface { + SetMany(ctx context.Context, values map[string]string) error +} + // LoadConfig reads rate limit settings from the settings store. // Missing keys fall back to DefaultConfig() values. func LoadConfig(ctx context.Context, store SettingsStore) (Config, error) { @@ -22,37 +26,43 @@ func LoadConfig(ctx context.Context, store SettingsStore) (Config, error) { if err != nil { return Config{}, fmt.Errorf("load rate limit config: %w", err) } + return ConfigFromSettings(all), nil +} +// ConfigFromSettings resolves rate-limit configuration from an already-read +// server_settings snapshot. Admin mutations use it while holding the shared +// settings transaction lock so validation and writes cannot race. +func ConfigFromSettings(all map[string]string) Config { defaults := DefaultConfig() cfg := Config{ Enabled: parseBool(all, "ratelimit.enabled", defaults.Enabled), - GlobalReqPerSecond: parseFloat(all, "ratelimit.global.requests_per_second", defaults.GlobalReqPerSecond), + GlobalReqPerSecond: parseRate(all, "ratelimit.global.requests_per_second", defaults.GlobalReqPerSecond, MaxGlobalRequestsPerSecond), Tiers: make(map[string]TierConfig), } for name, tier := range defaults.Tiers { prefix := "ratelimit.tier." + name + "." cfg.Tiers[name] = TierConfig{ - RequestsPerSecond: parseFloat(all, prefix+"requests_per_second", tier.RequestsPerSecond), - RequestsPerMinute: parseFloat(all, prefix+"requests_per_minute", tier.RequestsPerMinute), + RequestsPerSecond: parseRate(all, prefix+"requests_per_second", tier.RequestsPerSecond, MaxRequestsPerWindow), + RequestsPerMinute: parseRate(all, prefix+"requests_per_minute", tier.RequestsPerMinute, MaxRequestsPerWindow), Burst: parseInt(all, prefix+"burst", tier.Burst), } } - cfg.IPReqPerSecond = parseFloat(all, "ratelimit.ip.requests_per_second", defaults.IPReqPerSecond) - cfg.IPReqPerMinute = parseFloat(all, "ratelimit.ip.requests_per_minute", defaults.IPReqPerMinute) + cfg.IPReqPerSecond = parseRate(all, "ratelimit.ip.requests_per_second", defaults.IPReqPerSecond, MaxRequestsPerWindow) + cfg.IPReqPerMinute = parseRate(all, "ratelimit.ip.requests_per_minute", defaults.IPReqPerMinute, MaxRequestsPerWindow) cfg.IPBurst = parseInt(all, "ratelimit.ip.burst", defaults.IPBurst) cfg.AuthEndpoints = make(map[string]AuthEndpointConfig) for name, ep := range defaults.AuthEndpoints { prefix := "ratelimit.auth." + name + "." cfg.AuthEndpoints[name] = AuthEndpointConfig{ - RequestsPerMinute: parseFloat(all, prefix+"requests_per_minute", ep.RequestsPerMinute), + RequestsPerMinute: parseRate(all, prefix+"requests_per_minute", ep.RequestsPerMinute, MaxRequestsPerWindow), Burst: parseInt(all, prefix+"burst", ep.Burst), } } - return cfg, nil + return cfg } // SeedDefaults writes default rate limit settings if they don't exist yet. @@ -105,8 +115,11 @@ func SeedDefaults(ctx context.Context, store SettingsStore) error { return nil } -// SaveConfig persists rate limit settings to the store. -func SaveConfig(ctx context.Context, store SettingsStore, cfg Config) error { +// ConfigSettings serializes a runtime rate-limit config into server_settings +// values. Admin callers use the same mapping when they include the backend in +// one atomic transaction, avoiding drift between the dedicated endpoint and +// the runtime loader. +func ConfigSettings(cfg Config) map[string]string { pairs := map[string]string{ "ratelimit.enabled": strconv.FormatBool(cfg.Enabled), "ratelimit.global.requests_per_second": strconv.FormatFloat(cfg.GlobalReqPerSecond, 'f', -1, 64), @@ -125,6 +138,20 @@ func SaveConfig(ctx context.Context, store SettingsStore, cfg Config) error { pairs[prefix+"requests_per_minute"] = strconv.FormatFloat(ep.RequestsPerMinute, 'f', -1, 64) pairs[prefix+"burst"] = strconv.Itoa(ep.Burst) } + return pairs +} + +// SaveConfig persists rate limit settings to the store. Production stores +// support SetMany, so the full config changes atomically; the per-key fallback +// keeps small test and embedding stores backwards-compatible. +func SaveConfig(ctx context.Context, store SettingsStore, cfg Config) error { + pairs := ConfigSettings(cfg) + if writer, ok := store.(settingsBatchWriter); ok { + if err := writer.SetMany(ctx, pairs); err != nil { + return fmt.Errorf("save rate limit config: %w", err) + } + return nil + } for key, value := range pairs { if err := store.Set(ctx, key, value); err != nil { return fmt.Errorf("save rate limit config %s: %w", key, err) @@ -133,7 +160,7 @@ func SaveConfig(ctx context.Context, store SettingsStore, cfg Config) error { return nil } -// parseBool/parseFloat/parseInt are lenient helpers (silent fallback on parse error). +// parseBool/parseRate/parseInt are lenient helpers (silent fallback on parse error). // This is intentional: runtime config should not crash on a bad value in the DB. func parseBool(m map[string]string, key string, def bool) bool { @@ -145,22 +172,22 @@ func parseBool(m map[string]string, key string, def bool) bool { return def } -// parseFloat returns the stored value for key, falling back to def if the key -// is missing, unparseable, or <= 0 (which is never a valid rate limit value). -func parseFloat(m map[string]string, key string, def float64) float64 { +// parseRate returns the stored value for key, falling back to def if the key +// is missing, unparseable, outside the limiter's safe integer range, or <= 0. +func parseRate(m map[string]string, key string, def, maxValue float64) float64 { if v, ok := m[key]; ok { - if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 { + if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 && f <= maxValue { return f } } return def } -// parseInt returns the stored value for key, falling back to def if the key -// is missing, unparseable, or <= 0 (which is never a valid rate limit value). +// parseInt returns the stored value for key, falling back to def if the key is +// missing, unparseable, non-positive, or outside the limiter's portable range. func parseInt(m map[string]string, key string, def int) int { if v, ok := m[key]; ok { - if i, err := strconv.Atoi(v); err == nil && i > 0 { + if i, err := strconv.Atoi(v); err == nil && i > 0 && i <= MaxBurst { return i } } diff --git a/internal/ratelimit/config_test.go b/internal/ratelimit/config_test.go new file mode 100644 index 00000000..8f9a13dc --- /dev/null +++ b/internal/ratelimit/config_test.go @@ -0,0 +1,53 @@ +package ratelimit + +import ( + "context" + "testing" +) + +type mapSettingsStore map[string]string + +func (s mapSettingsStore) Get(_ context.Context, key string) (string, error) { + return s[key], nil +} + +func (s mapSettingsStore) Set(_ context.Context, key, value string) error { + s[key] = value + return nil +} + +func (s mapSettingsStore) GetAll(_ context.Context) (map[string]string, error) { + return s, nil +} + +func TestLoadConfigFallsBackFromRatesOutsideLimiterBounds(t *testing.T) { + defaults := DefaultConfig() + store := mapSettingsStore{ + "ratelimit.global.requests_per_second": "1e308", + "ratelimit.tier.standard.requests_per_second": "1e308", + "ratelimit.ip.requests_per_minute": "1e308", + "ratelimit.ip.burst": "9223372036854775807", + "ratelimit.auth.login.requests_per_minute": "1e308", + } + + cfg, err := LoadConfig(context.Background(), store) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + if cfg.GlobalReqPerSecond != defaults.GlobalReqPerSecond { + t.Errorf("global requests per second = %g, want default %g", cfg.GlobalReqPerSecond, defaults.GlobalReqPerSecond) + } + if cfg.Tiers["standard"].RequestsPerSecond != defaults.Tiers["standard"].RequestsPerSecond { + t.Errorf("standard requests per second = %g, want default %g", cfg.Tiers["standard"].RequestsPerSecond, defaults.Tiers["standard"].RequestsPerSecond) + } + if cfg.IPReqPerMinute != defaults.IPReqPerMinute { + t.Errorf("IP requests per minute = %g, want default %g", cfg.IPReqPerMinute, defaults.IPReqPerMinute) + } + if cfg.IPBurst != defaults.IPBurst { + t.Errorf("IP burst = %d, want default %d", cfg.IPBurst, defaults.IPBurst) + } + if cfg.AuthEndpoints["login"].RequestsPerMinute != defaults.AuthEndpoints["login"].RequestsPerMinute { + t.Errorf("login requests per minute = %g, want default %g", cfg.AuthEndpoints["login"].RequestsPerMinute, defaults.AuthEndpoints["login"].RequestsPerMinute) + } +} diff --git a/internal/ratelimit/middleware.go b/internal/ratelimit/middleware.go index e4cd2915..0422f35f 100644 --- a/internal/ratelimit/middleware.go +++ b/internal/ratelimit/middleware.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "math" "net/http" "strconv" "sync" @@ -91,11 +92,7 @@ func (mw *Middleware) Handler(next http.Handler) http.Handler { // Check global limiter (per-second only — spec says no per-minute global limit). // Setting RequestsPerMinute = RPS*60 makes the per-minute limiter a mathematical // no-op: it never triggers before the per-second limiter does. - globalRate := Rate{ - RequestsPerSecond: cfg.GlobalReqPerSecond, - RequestsPerMinute: cfg.GlobalReqPerSecond * 60, - Burst: int(cfg.GlobalReqPerSecond), - } + globalRate := globalRateFor(cfg) globalResult := mw.global.Allow(r.Context(), "global", globalRate) if !globalResult.Allowed { writeRateLimitResponse(w, globalResult) @@ -161,8 +158,9 @@ type rateLimitError struct { RetryAfter int `json:"retry_after"` } -// AuthEndpointHandler returns middleware for IP-based rate limiting on auth endpoints. -// It checks both the global per-IP limit and the tighter per-endpoint limit. +// AuthEndpointHandler returns middleware for public auth/webhook endpoints. It +// applies the shared per-IP and endpoint-specific budgets before consuming the +// process-wide budget, so one rejected client cannot drain capacity for others. func (mw *Middleware) AuthEndpointHandler(endpoint string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -176,37 +174,41 @@ func (mw *Middleware) AuthEndpointHandler(endpoint string) func(http.Handler) ht } clientIP := clientip.FromContext(r.Context()) - if clientIP == "" { - next.ServeHTTP(w, r) - return - } - - // Check global per-IP limit (shared counter with authenticated routes) - ipRate := Rate{ - RequestsPerSecond: cfg.IPReqPerSecond, - RequestsPerMinute: cfg.IPReqPerMinute, - Burst: cfg.IPBurst, - } - ipResult := mw.perKey.Allow(r.Context(), "ip:"+clientIP, ipRate) - if !ipResult.Allowed { - writeRateLimitResponse(w, ipResult) - return - } - - // Check per-endpoint limit - epCfg, ok := cfg.AuthEndpoints[endpoint] - if ok { - epRate := Rate{ - RequestsPerSecond: epCfg.RequestsPerMinute / 60, - RequestsPerMinute: epCfg.RequestsPerMinute, - Burst: epCfg.Burst, + if clientIP != "" { + // Check global per-IP limit (shared counter with authenticated routes) + ipRate := Rate{ + RequestsPerSecond: cfg.IPReqPerSecond, + RequestsPerMinute: cfg.IPReqPerMinute, + Burst: cfg.IPBurst, } - epKey := fmt.Sprintf("authip:%s:%s", clientIP, endpoint) - epResult := mw.perKey.Allow(r.Context(), epKey, epRate) - if !epResult.Allowed { - writeRateLimitResponse(w, epResult) + ipResult := mw.perKey.Allow(r.Context(), "ip:"+clientIP, ipRate) + if !ipResult.Allowed { + writeRateLimitResponse(w, ipResult) return } + + // Check per-endpoint limit + epCfg, ok := cfg.AuthEndpoints[endpoint] + if ok { + epRate := Rate{ + RequestsPerSecond: epCfg.RequestsPerMinute / 60, + RequestsPerMinute: epCfg.RequestsPerMinute, + Burst: epCfg.Burst, + } + epKey := fmt.Sprintf("authip:%s:%s", clientIP, endpoint) + epResult := mw.perKey.Allow(r.Context(), epKey, epRate) + if !epResult.Allowed { + writeRateLimitResponse(w, epResult) + return + } + } + } + + globalRate := globalRateFor(cfg) + globalResult := mw.global.Allow(r.Context(), "global", globalRate) + if !globalResult.Allowed { + writeRateLimitResponse(w, globalResult) + return } next.ServeHTTP(w, r) @@ -214,6 +216,20 @@ func (mw *Middleware) AuthEndpointHandler(endpoint string) func(http.Handler) ht } } +func globalRateFor(cfg Config) Rate { + requestsPerSecond := cfg.GlobalReqPerSecond + if math.IsNaN(requestsPerSecond) || math.IsInf(requestsPerSecond, 0) || requestsPerSecond <= 0 { + requestsPerSecond = DefaultConfig().GlobalReqPerSecond + } else if requestsPerSecond > MaxGlobalRequestsPerSecond { + requestsPerSecond = MaxGlobalRequestsPerSecond + } + return Rate{ + RequestsPerSecond: requestsPerSecond, + RequestsPerMinute: requestsPerSecond * 60, + Burst: max(1, int(math.Ceil(requestsPerSecond))), + } +} + func writeRateLimitResponse(w http.ResponseWriter, result AllowResult) { retrySeconds := int(result.RetryAfter.Seconds()) + 1 w.Header().Set("Retry-After", strconv.Itoa(retrySeconds)) diff --git a/internal/ratelimit/middleware_test.go b/internal/ratelimit/middleware_test.go new file mode 100644 index 00000000..e88555c2 --- /dev/null +++ b/internal/ratelimit/middleware_test.go @@ -0,0 +1,102 @@ +package ratelimit + +import ( + "context" + "net/http" + "net/http/httptest" + "slices" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/clientip" +) + +type recordingLimiter struct { + keys []string + denied map[string]bool +} + +func (l *recordingLimiter) Allow(_ context.Context, key string, limit Rate) AllowResult { + l.keys = append(l.keys, key) + return AllowResult{ + Allowed: !l.denied[key], + Limit: max(1, int(limit.RequestsPerSecond)), + Remaining: 1, + ResetAt: time.Now().Add(time.Second), + } +} + +func (*recordingLimiter) Close() {} + +func TestAuthEndpointHandlerUsesGlobalIPAndEndpointBudgets(t *testing.T) { + perKey := &recordingLimiter{} + global := &recordingLimiter{} + mw := NewMiddleware(perKey, global, nil, true) + mw.cfg = DefaultConfig() + nextCalled := false + handler := mw.AuthEndpointHandler("login")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusNoContent) + })) + req := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + req = req.WithContext(clientip.SetContext(req.Context(), "203.0.113.10")) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent || !nextCalled { + t.Fatalf("response = %d, next called = %v", rec.Code, nextCalled) + } + if !slices.Equal(global.keys, []string{"global"}) { + t.Fatalf("global limiter keys = %#v", global.keys) + } + if !slices.Equal(perKey.keys, []string{"ip:203.0.113.10", "authip:203.0.113.10:login"}) { + t.Fatalf("per-key limiter keys = %#v", perKey.keys) + } +} + +func TestAuthEndpointHandlerDoesNotConsumeGlobalBudgetForRejectedClient(t *testing.T) { + for _, deniedKey := range []string{"ip:203.0.113.10", "authip:203.0.113.10:login"} { + t.Run(deniedKey, func(t *testing.T) { + perKey := &recordingLimiter{denied: map[string]bool{deniedKey: true}} + global := &recordingLimiter{} + mw := NewMiddleware(perKey, global, nil, true) + mw.cfg = DefaultConfig() + handler := mw.AuthEndpointHandler("login")(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("next handler called for rejected request") + })) + req := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + req = req.WithContext(clientip.SetContext(req.Context(), "203.0.113.10")) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("response = %d, want 429", rec.Code) + } + if len(global.keys) != 0 { + t.Fatalf("global limiter keys = %#v, want no consumed budget", global.keys) + } + }) + } +} + +func TestGlobalRateSupportsFractionalRequestsPerSecond(t *testing.T) { + rate := globalRateFor(Config{GlobalReqPerSecond: 0.5}) + if rate.Burst != 1 { + t.Fatalf("burst = %d, want 1", rate.Burst) + } +} + +func TestGlobalRateBoundsUnsafeRequestsPerSecond(t *testing.T) { + rate := globalRateFor(Config{GlobalReqPerSecond: 1e308}) + if rate.RequestsPerSecond != MaxGlobalRequestsPerSecond { + t.Fatalf("requests per second = %g, want %g", rate.RequestsPerSecond, MaxGlobalRequestsPerSecond) + } + if rate.RequestsPerMinute > MaxRequestsPerWindow { + t.Fatalf("requests per minute = %g, must not exceed %g", rate.RequestsPerMinute, MaxRequestsPerWindow) + } + if rate.Burst <= 0 { + t.Fatalf("burst = %d, want a positive bounded value", rate.Burst) + } +} diff --git a/internal/ratelimit/ratelimit.go b/internal/ratelimit/ratelimit.go index 52f15057..b3119a85 100644 --- a/internal/ratelimit/ratelimit.go +++ b/internal/ratelimit/ratelimit.go @@ -5,6 +5,19 @@ import ( "time" ) +const ( + MaxBurst = 1<<31 - 1 + + // MaxRequestsPerWindow keeps the limiter's float-to-int conversions + // portable and bounded on both 32-bit and 64-bit platforms. + MaxRequestsPerWindow = float64(MaxBurst) + + // The global per-second rate is also expanded into a per-minute window. + // Use integer division so multiplying by 60 stays below the window limit + // without a floating-point rounding edge. + MaxGlobalRequestsPerSecond = float64(MaxBurst / 60) +) + // Rate defines the rate limits for a key. type Rate struct { RequestsPerSecond float64 diff --git a/internal/subtitles/pgrepo.go b/internal/subtitles/pgrepo.go index 50bcfcf1..02866fc6 100644 --- a/internal/subtitles/pgrepo.go +++ b/internal/subtitles/pgrepo.go @@ -228,3 +228,23 @@ func (r *PgRepository) UpsertProviderConfig(ctx context.Context, cfg *ProviderCo } return nil } + +// ClearProviderCredentials atomically disables a provider and removes every +// stored credential. The normal upsert deliberately treats blank values as +// "keep existing", so deletion must use this explicit path. +func (r *PgRepository) ClearProviderCredentials(ctx context.Context, providerName string) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO subtitle_provider_config (provider_name, enabled, api_key, username, password, updated_at) + VALUES ($1, false, '', '', '', NOW()) + ON CONFLICT (provider_name) DO UPDATE SET + enabled = false, + api_key = '', + username = '', + password = '', + updated_at = NOW() + `, providerName) + if err != nil { + return fmt.Errorf("clear subtitle provider credentials: %w", err) + } + return nil +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 882446ea..0030453c 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -3423,6 +3423,8 @@ export interface PluginAsset { export interface PluginConfigValue { key: string; value: Record; + /** Secret fields saved on the server but redacted from value. */ + configured_secrets?: string[]; } export interface PluginAuthBinding { @@ -3553,6 +3555,8 @@ export interface UpdatePluginInstallationRequest { export interface SavePluginConfigRequest { key: string; value: Record; + /** Explicitly clear these manifest-declared secret fields. */ + clear_secrets?: string[]; } export interface SavePluginAuthBindingRequest { @@ -4098,6 +4102,23 @@ export interface AdminSettingUpdateResponse { restart_required?: boolean; } +/** Response of the atomic PUT /admin/settings endpoint. */ +export interface AdminSettingsUpdateResponse { + /** Saved non-sensitive values. Secret values are intentionally omitted. */ + values: Record; + restart_required: boolean; + restart_required_keys?: string[]; +} + +export interface AdminServerStatus { + started_at: string; + restart_required: boolean; + restart_required_at?: string; + restart_required_reason?: string; + restart_requested: boolean; + restart_requested_at?: string; +} + // IP visibility export interface UserIPEntry { client_ip: string; @@ -4238,6 +4259,7 @@ export interface SubtitleProviderUpdateRequest { api_key?: string; username?: string; password?: string; + clear_credentials?: boolean; } export interface SubtitleProviderTestRequest { diff --git a/web/src/components/admin/plugins/PluginConfigForm.test.tsx b/web/src/components/admin/plugins/PluginConfigForm.test.tsx new file mode 100644 index 00000000..0ddb85e8 --- /dev/null +++ b/web/src/components/admin/plugins/PluginConfigForm.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import type { PluginConfigSchema } from "@/api/types"; + +import { PluginConfigForm } from "./PluginConfigForm"; + +const schema: PluginConfigSchema = { + key: "account", + title: "Account", + json_schema: "{}", + required: true, + admin_form: { + fields: [ + { + key: "api_key", + label: "API Key", + control: "PASSWORD", + required: false, + secret: true, + multiline: false, + }, + { + key: "region", + label: "Region", + control: "TEXT", + required: false, + secret: false, + multiline: false, + }, + ], + }, +}; + +describe("PluginConfigForm secrets", () => { + it("shows redacted saved state and only clears through an explicit action", async () => { + const onSave = vi.fn(); + render( + , + ); + + expect(screen.getByLabelText("API Key")).toHaveAttribute( + "placeholder", + "Saved secret — leave blank to keep", + ); + expect(screen.getByText("API Key: saved")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Save config" })); + expect(onSave).toHaveBeenLastCalledWith( + "account", + expect.objectContaining({ region: "us-east" }), + [], + ); + + await userEvent.click(screen.getByRole("button", { name: "Clear saved secret" })); + await userEvent.click(screen.getByRole("button", { name: "Save config" })); + expect(onSave).toHaveBeenLastCalledWith( + "account", + expect.objectContaining({ region: "us-east" }), + ["api_key"], + ); + }); + + it("does not offer to clear a required saved secret into an invalid config", () => { + const requiredSchema: PluginConfigSchema = { + ...schema, + admin_form: { + ...schema.admin_form!, + fields: schema.admin_form!.fields.map((field) => + field.key === "api_key" ? { ...field, required: true } : field, + ), + }, + }; + render( + , + ); + + expect(screen.getByText("API Key: saved (required)")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Clear saved secret" })).not.toBeInTheDocument(); + }); + + it("keeps the submitted snapshot immutable while a save is pending", () => { + render( + , + ); + + expect(screen.getByLabelText("Region")).toBeDisabled(); + expect(screen.getByRole("button", { name: "Save config" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Clear saved secret" })).toBeDisabled(); + }); + + it("tests the exact draft including staged secret removals", async () => { + const onTest = vi.fn().mockResolvedValue({ + success: false, + message: "API key is required", + }); + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Clear saved secret" })); + await userEvent.click(screen.getByRole("button", { name: "Check Connection" })); + + expect(onTest).toHaveBeenCalledWith("account", expect.objectContaining({ region: "us-east" }), [ + "api_key", + ]); + }); +}); diff --git a/web/src/components/admin/plugins/PluginConfigForm.tsx b/web/src/components/admin/plugins/PluginConfigForm.tsx index acf9f754..4dbc1590 100644 --- a/web/src/components/admin/plugins/PluginConfigForm.tsx +++ b/web/src/components/admin/plugins/PluginConfigForm.tsx @@ -18,8 +18,13 @@ type PluginConfigValue = Record; type Props = { schema: PluginConfigSchema; value?: PluginConfigValue; - onSave: (key: string, value: PluginConfigValue) => void; - onTest?: (key: string, value: PluginConfigValue) => Promise; + configuredSecrets?: string[]; + onSave: (key: string, value: PluginConfigValue, clearSecrets: string[]) => void; + onTest?: ( + key: string, + value: PluginConfigValue, + clearSecrets: string[], + ) => Promise; isSaving?: boolean; isTesting?: boolean; }; @@ -46,7 +51,16 @@ function parseJSONSchema(schema: PluginConfigSchema): ParsedObjectSchema { const parsed = JSON.parse(schema.json_schema) as { type?: string; required?: string[]; - properties?: Record; + properties?: Record< + string, + { + type?: string; + title?: string; + description?: string; + writeOnly?: boolean; + format?: string; + } + >; }; if (parsed.type !== "object" || !parsed.properties) { return { supported: false, fields: [] }; @@ -57,12 +71,15 @@ function parseJSONSchema(schema: PluginConfigSchema): ParsedObjectSchema { if (!propertyType || !["string", "number", "integer", "boolean"].includes(propertyType)) { return null; } + const isSensitive = property.writeOnly === true || property.format === "password"; const control = propertyType === "boolean" ? "SWITCH" : propertyType === "number" || propertyType === "integer" ? "NUMBER" - : "TEXT"; + : isSensitive + ? "PASSWORD" + : "TEXT"; return { key, label: property.title || humanizeKey(key), @@ -70,7 +87,7 @@ function parseJSONSchema(schema: PluginConfigSchema): ParsedObjectSchema { control, placeholder: "", required: parsed.required?.includes(key) ?? false, - secret: false, + secret: isSensitive, multiline: false, options: [], rows: 0, @@ -122,6 +139,7 @@ function valueForField(field: SupportedField, configValue?: PluginConfigValue): export function PluginConfigForm({ schema, value, + configuredSecrets = [], onSave, onTest, isSaving = false, @@ -138,23 +156,43 @@ export function PluginConfigForm({ const supported = fields.length > 0 && (schema.admin_form?.fields?.length ? true : parsedFallback.supported); - const descriptor = useMemo( - () => schema.admin_form ?? { fields }, - [schema.admin_form, fields], - ); + const descriptor = useMemo(() => { + const base = schema.admin_form ?? { fields }; + const configured = new Set(configuredSecrets); + return { + ...base, + fields: base.fields.map((field) => + configured.has(field.key) && (field.secret || field.control === "PASSWORD") + ? { ...field, placeholder: "Saved secret — leave blank to keep" } + : field, + ), + }; + }, [configuredSecrets, fields, schema.admin_form]); const [values, setValues] = useState(() => Object.fromEntries(fields.map((field) => [field.key, valueForField(field, value)])), ); const [testResult, setTestResult] = useState(null); + const [clearSecrets, setClearSecrets] = useState>(new Set()); useEffect(() => { setValues(Object.fromEntries(fields.map((field) => [field.key, valueForField(field, value)]))); + setClearSecrets(new Set()); }, [fields, value]); function handleChange(next: PluginConfigValue) { setTestResult(null); setValues(next); + setClearSecrets((current) => { + const updated = new Set(current); + for (const key of configuredSecrets) { + const replacement = next[key]; + if (typeof replacement === "string" && replacement.trim() !== "") { + updated.delete(key); + } + } + return updated; + }); } async function handleTest() { @@ -163,7 +201,9 @@ export function PluginConfigForm({ } try { - setTestResult(await onTest(schema.key, buildSchemaValues(descriptor, values))); + setTestResult( + await onTest(schema.key, buildSchemaValues(descriptor, values), Array.from(clearSecrets)), + ); } catch (error) { setTestResult({ success: false, @@ -184,7 +224,7 @@ export function PluginConfigForm({ } return ( -
+
{schema.description ? ( @@ -199,6 +239,41 @@ export function PluginConfigForm({ idPrefix={schema.key} /> + {configuredSecrets.length > 0 ? ( +
+ {configuredSecrets.map((key) => { + const field = fields.find((candidate) => candidate.key === key); + const clearing = clearSecrets.has(key); + const required = field?.required === true; + return ( +
+ + {field?.label || humanizeKey(key)}: {clearing ? "will be cleared" : "saved"} + {required ? " (required)" : ""} + + {!required ? ( + + ) : null} +
+ ); + })} +
+ ) : null} +
{onTest ? ( onSave(schema.key, buildSchemaValues(descriptor, values))} + onClick={() => + onSave(schema.key, buildSchemaValues(descriptor, values), Array.from(clearSecrets)) + } > {schema.admin_form?.submit_label || "Save config"}
-
+
); } diff --git a/web/src/components/theme/RawCssEditor.tsx b/web/src/components/theme/RawCssEditor.tsx index f4ee7913..8ea12c7e 100644 --- a/web/src/components/theme/RawCssEditor.tsx +++ b/web/src/components/theme/RawCssEditor.tsx @@ -41,7 +41,7 @@ export function RawCssEditor({ value, onChange }: RawCssEditorProps) {

- Changes apply immediately. Saved automatically. + Preview updates as you type. Saved automatically after you pause.

) { - queryClient.invalidateQueries({ queryKey: adminKeys.pluginRepositories() }); - queryClient.invalidateQueries({ queryKey: adminKeys.pluginCatalog() }); - queryClient.invalidateQueries({ queryKey: adminKeys.pluginInstallations() }); - queryClient.invalidateQueries({ queryKey: adminKeys.pluginCatalogSettings() }); + return Promise.all([ + queryClient.invalidateQueries({ queryKey: adminKeys.pluginRepositories() }), + queryClient.invalidateQueries({ queryKey: adminKeys.pluginCatalog() }), + queryClient.invalidateQueries({ queryKey: adminKeys.pluginInstallations() }), + queryClient.invalidateQueries({ queryKey: adminKeys.pluginCatalogSettings() }), + ]); } // useAdminPluginInstallations is a slim hook for callers (e.g. AdminSidebar) @@ -328,9 +330,9 @@ export function useSavePluginConfig() { method: "PUT", body: JSON.stringify(body), }), - onSuccess: () => { + onSuccess: async () => { toast.success("Plugin config saved"); - invalidatePluginQueries(queryClient); + await invalidatePluginQueries(queryClient); }, onError: (error) => { toast.error(error instanceof Error ? error.message : "Failed to save plugin config"); @@ -357,7 +359,7 @@ export function useSavePluginAuthBinding() { body: JSON.stringify(body), }), onSuccess: () => { - toast.success("Auth binding saved"); + toast.success("Auth binding saved — restart the server to apply it"); invalidatePluginQueries(queryClient); }, onError: (error) => { @@ -385,8 +387,12 @@ export function useSavePluginTaskBinding() { body: JSON.stringify(body), }, ), - onSuccess: () => { - toast.success("Task binding saved"); + onSuccess: (data) => { + toast.success( + data.restart_required + ? "Task binding saved — restart the server to apply it" + : "Task binding saved", + ); invalidatePluginQueries(queryClient); }, onError: (error) => { diff --git a/web/src/hooks/queries/admin/rateLimits.ts b/web/src/hooks/queries/admin/rateLimits.ts index 601197cb..1992ce62 100644 --- a/web/src/hooks/queries/admin/rateLimits.ts +++ b/web/src/hooks/queries/admin/rateLimits.ts @@ -22,13 +22,16 @@ export function useUpdateRateLimitConfig() { method: "PUT", body: JSON.stringify(config), }), - onSuccess: (data) => { + onSuccess: async (data) => { if (data.restart_required) { toast.success("Rate limit settings saved — restart the server to apply them"); } else { toast.success("Rate limit settings saved"); } - queryClient.invalidateQueries({ queryKey: adminKeys.rateLimitConfig() }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: adminKeys.rateLimitConfig() }), + queryClient.invalidateQueries({ queryKey: adminKeys.serverStatus() }), + ]); }, onError: (err) => { toast.error(err instanceof Error ? err.message : "Failed to save rate limit settings"); diff --git a/web/src/hooks/queries/admin/settings.ts b/web/src/hooks/queries/admin/settings.ts index 72979049..aa52f5fa 100644 --- a/web/src/hooks/queries/admin/settings.ts +++ b/web/src/hooks/queries/admin/settings.ts @@ -2,6 +2,8 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "@/api/client"; import type { AdminSettingUpdateResponse, + AdminServerStatus, + AdminSettingsUpdateResponse, AdminSettingsConnectionCheckRequest, ConnectionCheckResponse, JellyfinCompatSettingsPatch, @@ -77,11 +79,60 @@ export interface CatalogSearchStatus { export function useAdminServerSettings() { return useQuery({ queryKey: adminKeys.serverSettings(), - queryFn: () => api("/admin/settings").then((d) => d ?? {}), + queryFn: () => api("/admin/settings/effective").then((d) => d ?? {}), staleTime: 30_000, }); } +export function useAdminServerStatus() { + return useQuery({ + queryKey: adminKeys.serverStatus(), + queryFn: () => api("/admin/server/status"), + staleTime: 15_000, + }); +} + +export function useUpdateServerSettings() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (values: Record) => + api("/admin/settings", { + method: "PUT", + body: JSON.stringify({ values }), + }), + onSuccess: async (_data, values) => { + const keys = Object.keys(values); + const invalidations = [ + queryClient.invalidateQueries({ queryKey: adminKeys.serverSettings() }), + queryClient.invalidateQueries({ queryKey: adminKeys.serverStatus() }), + queryClient.invalidateQueries({ + queryKey: [...adminKeys.serverSettings(), "sensitive-status"] as const, + }), + ]; + if (keys.some((key) => key.startsWith("jellyfin_compat."))) { + invalidations.push( + queryClient.invalidateQueries({ queryKey: adminKeys.jellyfinCompatStatus() }), + ); + } + if (keys.some((key) => key.startsWith("catalog.search."))) { + invalidations.push( + queryClient.invalidateQueries({ queryKey: adminKeys.catalogSearchStatus() }), + ); + } + if (keys.some((key) => key.startsWith("branding.") || key.startsWith("ui.admin_"))) { + invalidations.push( + queryClient.invalidateQueries({ queryKey: themeKeys.adminCss() }), + queryClient.invalidateQueries({ queryKey: themeKeys.branding() }), + ); + } + await Promise.all(invalidations); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to update settings"); + }, + }); +} + export function useUpdateServerSetting() { const queryClient = useQueryClient(); return useMutation({ @@ -93,6 +144,7 @@ export function useUpdateServerSetting() { onSuccess: async (_data, variables) => { const invalidations = [ queryClient.invalidateQueries({ queryKey: adminKeys.serverSettings() }), + queryClient.invalidateQueries({ queryKey: adminKeys.serverStatus() }), queryClient.invalidateQueries({ queryKey: [...adminKeys.serverSettings(), "sensitive-status"] as const, }), @@ -171,6 +223,7 @@ export function useUpdateJellyfinCompatSettings() { await Promise.all([ queryClient.invalidateQueries({ queryKey: adminKeys.jellyfinCompatStatus() }), queryClient.invalidateQueries({ queryKey: adminKeys.serverSettings() }), + queryClient.invalidateQueries({ queryKey: adminKeys.serverStatus() }), ]); }, onError: (err) => { @@ -192,6 +245,7 @@ export function useInstallJellyfinCompatWeb() { await Promise.all([ queryClient.invalidateQueries({ queryKey: adminKeys.jellyfinCompatStatus() }), queryClient.invalidateQueries({ queryKey: adminKeys.serverSettings() }), + queryClient.invalidateQueries({ queryKey: adminKeys.serverStatus() }), ]); }, onError: (err) => { @@ -213,6 +267,7 @@ export function useRemoveJellyfinCompatWeb() { await Promise.all([ queryClient.invalidateQueries({ queryKey: adminKeys.jellyfinCompatStatus() }), queryClient.invalidateQueries({ queryKey: adminKeys.serverSettings() }), + queryClient.invalidateQueries({ queryKey: adminKeys.serverStatus() }), ]); }, onError: (err) => { diff --git a/web/src/hooks/queries/admin/subtitles.ts b/web/src/hooks/queries/admin/subtitles.ts index e95d60b8..ff9f73db 100644 --- a/web/src/hooks/queries/admin/subtitles.ts +++ b/web/src/hooks/queries/admin/subtitles.ts @@ -7,6 +7,7 @@ import type { AdminUpdateDownloadedSubtitleRequest, SubtitleProviderConfig, SubtitleProviderUpdateRequest, + SubtitleProviderTestRequest, SubtitleProviderTestResponse, } from "@/api/types"; import { adminKeys } from "../keys"; @@ -101,9 +102,9 @@ export function useUpdateSubtitleProvider() { method: "PUT", body: JSON.stringify(config), }), - onSuccess: () => { + onSuccess: async () => { toast.success("Provider settings saved"); - queryClient.invalidateQueries({ + await queryClient.invalidateQueries({ queryKey: adminKeys.subtitleProviders(), }); }, @@ -115,9 +116,10 @@ export function useUpdateSubtitleProvider() { export function useTestSubtitleProvider() { return useMutation({ - mutationFn: (provider: string) => + mutationFn: ({ provider, config }: { provider: string; config: SubtitleProviderTestRequest }) => api(`/admin/subtitle-providers/${provider}/test`, { method: "POST", + body: JSON.stringify(config), }), }); } diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index 96f63df6..21f0f41a 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -370,6 +370,7 @@ export const adminKeys = { stats: () => ["admin", "stats"] as const, sessions: () => ["admin", "sessions"] as const, serverSettings: () => ["admin", "serverSettings"] as const, + serverStatus: () => ["admin", "serverStatus"] as const, catalogSearchStatus: () => ["admin", "catalogSearchStatus"] as const, jellyfinCompatStatus: () => ["admin", "jellyfinCompatStatus"] as const, requestsRoot: () => ["admin", "requests"] as const, diff --git a/web/src/hooks/queries/theme.ts b/web/src/hooks/queries/theme.ts index 1211b1a4..5a8df97e 100644 --- a/web/src/hooks/queries/theme.ts +++ b/web/src/hooks/queries/theme.ts @@ -14,8 +14,16 @@ export function useAdminPublicCss() { queryFn: async () => { try { const result = await api("/theme/admin-css"); + let vars: Record = {}; + if (result.vars) { + try { + vars = JSON.parse(result.vars) as Record; + } catch { + // Keep valid raw CSS active even if a legacy vars row is corrupt. + } + } return { - vars: result.vars ? (JSON.parse(result.vars) as Record) : {}, + vars, rawCss: result.raw_css ?? "", }; } catch { diff --git a/web/src/hooks/useSettingsForm.test.ts b/web/src/hooks/useSettingsForm.test.ts index 185b2a09..52e7fc9d 100644 --- a/web/src/hooks/useSettingsForm.test.ts +++ b/web/src/hooks/useSettingsForm.test.ts @@ -17,7 +17,7 @@ const sensitiveData = { configured: [], managed_by_env: [] }; vi.mock("@/hooks/queries/admin/settings", () => ({ useAdminServerSettings: () => ({ data: settingsData, isLoading: false }), useAdminSensitiveStatus: () => ({ data: sensitiveData }), - useUpdateServerSetting: () => ({ mutateAsync, isPending: false }), + useUpdateServerSettings: () => ({ mutateAsync, isPending: false }), })); afterEach(() => { @@ -27,7 +27,10 @@ afterEach(() => { describe("useSettingsForm save()", () => { it("does not flag a restart when no saved key requires one", async () => { - mutateAsync.mockResolvedValue({ key: "branding.server_name", restart_required: false }); + mutateAsync.mockResolvedValue({ + values: { "branding.server_name": "Casa" }, + restart_required: false, + }); const { result } = renderHook(() => useSettingsForm({ keys: KEYS })); @@ -38,13 +41,82 @@ describe("useSettingsForm save()", () => { await result.current.save(); }); - expect(mutateAsync).toHaveBeenCalledWith({ key: "branding.server_name", value: "Casa" }); + expect(mutateAsync).toHaveBeenCalledWith({ "branding.server_name": "Casa" }); expect(result.current.restartRequired).toBe(false); }); + it("adopts canonical server values after save", async () => { + mutateAsync.mockResolvedValue({ + values: { "database.max_connections": "40" }, + restart_required: true, + }); + const { result } = renderHook(() => useSettingsForm({ keys: KEYS })); + + act(() => { + result.current.setValue("database.max_connections", " 40 "); + }); + await act(async () => { + await result.current.save(); + }); + + expect(result.current.getValue("database.max_connections")).toBe("40"); + expect(result.current.dirtyCount).toBe(0); + }); + + it("erases a sensitive draft after the server omits it from the response", async () => { + mutateAsync.mockResolvedValue({ values: {}, restart_required: false }); + const { result } = renderHook(() => useSettingsForm({ keys: ["email.smtp_password"] })); + + act(() => { + result.current.setValue("email.smtp_password", "temporary-secret"); + }); + await act(async () => { + await result.current.save(); + }); + + expect(result.current.getValue("email.smtp_password")).toBe(""); + expect(result.current.dirtyCount).toBe(0); + }); + + it("preserves edits made while a save is in flight", async () => { + let resolveMutation: + | ((value: { values: Record; restart_required: boolean }) => void) + | undefined; + mutateAsync.mockReturnValue( + new Promise((resolve) => { + resolveMutation = resolve; + }), + ); + const { result } = renderHook(() => useSettingsForm({ keys: KEYS })); + + act(() => { + result.current.setValue("branding.server_name", "Casa"); + }); + let savePromise: Promise | undefined; + act(() => { + savePromise = result.current.save(); + }); + act(() => { + result.current.setValue("branding.server_name", "Villa"); + }); + await act(async () => { + resolveMutation?.({ + values: { "branding.server_name": "Casa" }, + restart_required: false, + }); + await savePromise; + }); + + expect(result.current.getValue("branding.server_name")).toBe("Villa"); + expect(result.current.dirtyCount).toBe(1); + }); + it("flags a restart when any saved key requires one, and keeps it flagged", async () => { - mutateAsync.mockImplementation(({ key }: { key: string }) => - Promise.resolve({ key, restart_required: key === "database.max_connections" }), + mutateAsync.mockImplementation((values: Record) => + Promise.resolve({ + values, + restart_required: "database.max_connections" in values, + }), ); const { result } = renderHook(() => useSettingsForm({ keys: KEYS })); diff --git a/web/src/hooks/useSettingsForm.ts b/web/src/hooks/useSettingsForm.ts index 8d20f3ee..744e5bdc 100644 --- a/web/src/hooks/useSettingsForm.ts +++ b/web/src/hooks/useSettingsForm.ts @@ -1,8 +1,8 @@ -import { useState, useEffect, useCallback, useMemo } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import type { AdminSettingsConnectionCheckRequest } from "@/api/types"; import { useAdminServerSettings, - useUpdateServerSetting, + useUpdateServerSettings, useAdminSensitiveStatus, } from "@/hooks/queries/admin/settings"; @@ -14,26 +14,45 @@ interface UseSettingsFormOptions { export function useSettingsForm({ keys }: UseSettingsFormOptions) { const { data: settings, isLoading } = useAdminServerSettings(); const { data: sensitiveData } = useAdminSensitiveStatus(); - const updateSetting = useUpdateServerSetting(); + const updateSettings = useUpdateServerSettings(); const [localValues, setLocalValues] = useState>({}); const [dirty, setDirty] = useState>(new Set()); const [restartRequired, setRestartRequired] = useState(false); + const editVersions = useRef(new Map()); + const dirtyRef = useRef(dirty); + useEffect(() => { + dirtyRef.current = dirty; + }, [dirty]); + + // Treat equivalent key lists as stable even if a caller constructs the + // array inline. This keeps server hydration tied to actual query/key changes + // instead of every render. + const keySignature = keys.join("\u0000"); + const stableKeys = useMemo( + () => (keySignature === "" ? [] : keySignature.split("\u0000")), + [keySignature], + ); // Sync from server when settings load useEffect(() => { if (!settings) return; setLocalValues((prev) => { const next = { ...prev }; - for (const key of keys) { + let changed = false; + for (const key of stableKeys) { // Only set if not dirty (user hasn't edited) - if (!dirty.has(key)) { - next[key] = settings[key] ?? ""; + if (!dirtyRef.current.has(key)) { + const serverValue = settings[key] ?? ""; + if (next[key] !== serverValue) { + next[key] = serverValue; + changed = true; + } } } - return next; + return changed ? next : prev; }); - }, [settings, keys, dirty]); + }, [settings, stableKeys]); const getValue = useCallback( (key: string) => localValues[key] ?? settings?.[key] ?? "", @@ -41,10 +60,27 @@ export function useSettingsForm({ keys }: UseSettingsFormOptions) { ); const setValue = useCallback((key: string, value: string) => { + editVersions.current.set(key, (editVersions.current.get(key) ?? 0) + 1); setLocalValues((prev) => ({ ...prev, [key]: value })); setDirty((prev) => new Set(prev).add(key)); }, []); + // Revert one staged field without disturbing other edits. This is also how + // a redacted secret toggle can cancel a pending clear without needing the + // server to send the secret back to the browser. + const resetValue = useCallback( + (key: string) => { + editVersions.current.set(key, (editVersions.current.get(key) ?? 0) + 1); + setLocalValues((prev) => ({ ...prev, [key]: settings?.[key] ?? "" })); + setDirty((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + }, + [settings], + ); + const dirtyCount = dirty.size; const dirtyKeys = useMemo(() => Array.from(dirty), [dirty]); @@ -61,23 +97,45 @@ export function useSettingsForm({ keys }: UseSettingsFormOptions) { ); const save = useCallback(async () => { - const promises = Array.from(dirty).map((key) => - updateSetting.mutateAsync({ key, value: localValues[key] ?? "" }), + if (dirty.size === 0) return; + const submittedKeys = Array.from(dirty); + const values = Object.fromEntries(submittedKeys.map((key) => [key, localValues[key] ?? ""])); + const submittedVersions = new Map( + submittedKeys.map((key) => [key, editVersions.current.get(key) ?? 0]), ); - const results = await Promise.all(promises); - setDirty(new Set()); - // The backend reports per key whether a restart is needed; most settings - // hot-reload. Once a restart-required key was saved, keep the banner up - // until the server actually restarts. - if (results.some((r) => r?.restart_required)) { + const result = await updateSettings.mutateAsync(values); + const settledKeys = submittedKeys.filter( + (key) => (editVersions.current.get(key) ?? 0) === submittedVersions.get(key), + ); + setLocalValues((previous) => { + const next = { ...previous }; + for (const key of settledKeys) { + // The server returns canonical non-secret values. Sensitive values are + // intentionally omitted, so erase those drafts after a successful save + // instead of retaining plaintext credentials in component state. + next[key] = result.values[key] ?? ""; + } + return next; + }); + setDirty((current) => { + const next = new Set(current); + for (const key of settledKeys) { + next.delete(key); + } + return next; + }); + // Once a restart-required batch was saved, keep the banner up until the + // server actually restarts. + if (result.restart_required) { setRestartRequired(true); } - }, [dirty, localValues, updateSetting]); + }, [dirty, localValues, updateSettings]); const discard = useCallback(() => { if (!settings) return; const reset: Record = {}; for (const key of keys) { + editVersions.current.set(key, (editVersions.current.get(key) ?? 0) + 1); reset[key] = settings[key] ?? ""; } setLocalValues((prev) => ({ ...prev, ...reset })); @@ -91,12 +149,13 @@ export function useSettingsForm({ keys }: UseSettingsFormOptions) { isLoading, getValue, setValue, + resetValue, dirtyCount, dirtyKeys, isDirty, save, discard, - isSaving: updateSetting.isPending, + isSaving: updateSettings.isPending, restartRequired, sensitiveConfigured, sensitiveManagedByEnv, diff --git a/web/src/lib/adminSettingsSearch.ts b/web/src/lib/adminSettingsSearch.ts index 7c9672ef..7ffbc30e 100644 --- a/web/src/lib/adminSettingsSearch.ts +++ b/web/src/lib/adminSettingsSearch.ts @@ -292,8 +292,13 @@ export const ADMIN_SETTINGS_GROUPS: AdminSettingsSearchGroup[] = [ id: "watch-providers", label: "Watch Providers", description: "Provider integrations for watch history and scrobbling.", - keywords: ["trakt", "import", "export", "scrobble", "watch history", "favorites"], - settings: settingIndex("Client ID", "Client Secret"), + keywords: ["trakt", "simkl", "import", "export", "scrobble", "watch history", "favorites"], + settings: settingIndex( + "Trakt Client ID", + "Trakt Client Secret", + "Simkl Client ID", + "Simkl Client Secret", + ), icon: Cloud, }, { @@ -355,6 +360,7 @@ export const ADMIN_SETTINGS_GROUPS: AdminSettingsSearchGroup[] = [ "Relay URL", "Deployment ID", "Register Relay", + "Clear Relay Credential", "Privacy Disclosure", "Email", "Allow Per-Episode Email", @@ -364,6 +370,8 @@ export const ADMIN_SETTINGS_GROUPS: AdminSettingsSearchGroup[] = [ "Client ID", "Client Secret", "Bot Token", + "Invite Bot to Server", + "Clear Discord Credentials", "Allow Per-Episode DMs", "Embed Posters", "Personal Webhooks", diff --git a/web/src/lib/overlays/registry/ribbons.ts b/web/src/lib/overlays/registry/ribbons.ts index 11a0a621..edff46f6 100644 --- a/web/src/lib/overlays/registry/ribbons.ts +++ b/web/src/lib/overlays/registry/ribbons.ts @@ -1,11 +1,8 @@ import type { OverlayDef } from "../types"; -// "Ribbon" overlays — status/award badges that sit in the visual register -// of corner ribbons. They're shipped as registry entries today, but most -// rely on data sources that don't yet flow (IMDb Top 250 rankings, RT -// Certified Fresh flag). getValue returns null until the data is populated, -// so they're invisible on real cards — only the settings UI shows them with -// an "(awaiting data)" note. +// "Ribbon" overlays — status badges that sit in the visual register of corner +// ribbons. Only entries backed by a real API field belong in this registry; +// registry membership makes a control visible in Settings. function formatShowStatus(value: string | undefined): string | null { if (!value) return null; @@ -42,30 +39,4 @@ export const RIBBON_OVERLAYS: readonly OverlayDef[] = [ availabilityNote: "Populated by metadata plugins (TMDB/TVDB updates pending)", getValue: (d) => formatShowStatus(d.show_status), }, - { - id: "imdb_top_250", - category: "ribbons", - label: "IMDb Top 250", - description: "Rank when present in the IMDb Top 250 chart", - defaultPosition: "top-right", - defaultEnabled: false, - iconId: "ribbon", - defaultAccent: "#f5c518", - iconCapable: true, - availabilityNote: "Requires an IMDb Top 250 data source (planned)", - getValue: (d) => (d.imdb_top_250 != null ? `#${d.imdb_top_250}` : null), - }, - { - id: "rt_certified_fresh", - category: "ribbons", - label: "RT Certified Fresh", - description: "Shown for Rotten Tomatoes Certified Fresh titles", - defaultPosition: "top-right", - defaultEnabled: false, - iconId: "tomato", - defaultAccent: "#fa320a", - iconCapable: true, - availabilityNote: "Requires Rotten Tomatoes certification data (planned)", - getValue: (d) => (d.rt_certified_fresh ? "Certified Fresh" : null), - }, ]; diff --git a/web/src/lib/settingsManifest.test.ts b/web/src/lib/settingsManifest.test.ts new file mode 100644 index 00000000..8a1f9e63 --- /dev/null +++ b/web/src/lib/settingsManifest.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { ALL_DEVICE_SETTING_KEYS, getSettingDefinition } from "./settingsManifest"; + +describe("settingsManifest", () => { + it("keeps the client-consumed credits override editable", () => { + expect(ALL_DEVICE_SETTING_KEYS).toContain("playback.auto_skip_credits"); + expect(getSettingDefinition("playback.auto_skip_credits")).toMatchObject({ + scope: "device", + control: "switch", + defaultValue: "false", + }); + }); + + it("does not expose inert profile-only playback fields as device overrides", () => { + expect(ALL_DEVICE_SETTING_KEYS).not.toContain("playback.auto_skip_recap"); + expect(ALL_DEVICE_SETTING_KEYS).not.toContain("playback.auto_play_next_preview"); + }); +}); diff --git a/web/src/lib/settingsManifest.ts b/web/src/lib/settingsManifest.ts index fa69ce92..d3974225 100644 --- a/web/src/lib/settingsManifest.ts +++ b/web/src/lib/settingsManifest.ts @@ -75,23 +75,6 @@ const definitions: SettingDefinition[] = [ control: "switch", defaultValue: "false", }, - { - key: "playback.auto_skip_recap", - scope: "device", - label: "Auto-skip recaps", - description: "Skip 'previously on…' recaps automatically when Silo can detect them.", - control: "switch", - defaultValue: "false", - }, - { - key: "playback.auto_play_next_preview", - scope: "device", - label: "Start next episode at preview", - description: - "Begin playing the next episode when the current one reaches its next-episode preview teaser, rather than waiting for the end credits.", - control: "switch", - defaultValue: "false", - }, { key: "playback.auto_play_next", scope: "device", @@ -140,6 +123,14 @@ const definitions: SettingDefinition[] = [ control: "switch", defaultValue: "true", }, + { + key: "player.dolby_vision_enabled", + scope: "device", + label: "Dolby Vision enabled", + description: "Allow Dolby Vision playback when this device and display support it.", + control: "switch", + defaultValue: "true", + }, { key: "player.dv_profile7_hdr10_fallback", scope: "device", @@ -148,6 +139,14 @@ const definitions: SettingDefinition[] = [ control: "switch", defaultValue: "false", }, + { + key: "player.seek_cache_enabled", + scope: "device", + label: "Seek cache", + description: "Cache nearby playback data to make repeated seeks faster on this device.", + control: "switch", + defaultValue: "true", + }, { key: "player.playback_speed", scope: "device", diff --git a/web/src/pages/AdminPlugins.tsx b/web/src/pages/AdminPlugins.tsx index c3bd747f..e09f34c7 100644 --- a/web/src/pages/AdminPlugins.tsx +++ b/web/src/pages/AdminPlugins.tsx @@ -75,6 +75,7 @@ import { useSavePluginAuthBinding, useSavePluginConfig, useSavePluginTaskBinding, + useTestPluginConfig, useUpdatePluginInstallation, useUpdatePluginCatalogSettings, useUpdatePluginRepository, @@ -282,6 +283,7 @@ function InstalledPluginCard({ const updateInstallation = useUpdatePluginInstallation(); const deleteInstallation = useDeletePluginInstallation(); const applyUpdate = useApplyPluginUpdate(); + const [confirmDelete, setConfirmDelete] = useState(false); const capabilities = installation.capabilities ?? []; const presentation = installation.presentation ?? catalogEntry?.presentation; const repoURL = installation.repo_url || catalogEntry?.repo_url; @@ -291,160 +293,193 @@ function InstalledPluginCard({ ); return ( -
-
- {/* Left: icon + info */} -
-
- -
-
-
-

- {pluginDisplayName(installation.plugin_id, presentation)} -

- - {installation.version} - - - {sourceLabel(installation.source_kind)} - - {installation.available_version && ( - - {installation.version} → {installation.available_version} available - - )} - {installation.updates_paused ? ( - - Updates paused - - ) : null} - {installation.available_version && ( - - )} - - - - {installation.enabled ? "Active" : "Inactive"} - - + <> +
+
+ {/* Left: icon + info */} +
+
+
-

{installation.plugin_id}

-

- {pluginSummary(presentation, capabilities)} -

- {capabilities.length > 0 && ( -
- {capabilities.map((cap) => ( - +
+

+ {pluginDisplayName(installation.plugin_id, presentation)} +

+ + {installation.version} + + + {sourceLabel(installation.source_kind)} + + {installation.available_version && ( + - {cap.display_name || capabilityLabel(cap.type)} - - ))} -
- )} - -
-
- - {/* Right: actions */} -
- {adminRoutes.length > 0 ? ( - <> - {adminRoutes.map((route) => { - const href = pluginRouteHref(installation.id, route.path); - return ( + {installation.version} → {installation.available_version} available + + )} + {installation.updates_paused ? ( + + Updates paused + + ) : null} + {installation.available_version && ( - ); - })} - - - ) : ( - - )} - -
- - updateInstallation.mutate({ id: installation.id, body: { enabled: checked } }) - } - /> + )} + + + + {installation.enabled ? "Active" : "Inactive"} + + +
+

+ {installation.plugin_id} +

+

+ {pluginSummary(presentation, capabilities)} +

+ {capabilities.length > 0 && ( +
+ {capabilities.map((cap) => ( + + {cap.display_name || capabilityLabel(cap.type)} + + ))} +
+ )} + +
+
+ + {/* Right: actions */} +
+ {adminRoutes.length > 0 ? ( + <> + {adminRoutes.map((route) => { + const href = pluginRouteHref(installation.id, route.path); + return ( + + ); + })} + + + ) : ( + + )} + +
+ + updateInstallation.mutate({ id: installation.id, body: { enabled: checked } }) + } + /> +
+
-
-
+ + + + + Uninstall {pluginDisplayName(installation.plugin_id, presentation)}? + + + Silo will stop the plugin, then remove its installation, configuration, and installed + files. This cannot be undone. + + + + Cancel + deleteInstallation.mutate(installation.id)} + disabled={deleteInstallation.isPending} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleteInstallation.isPending ? "Uninstalling..." : "Uninstall plugin"} + + + + + ); } @@ -458,6 +493,7 @@ function ConfigureDialog({ onClose: () => void; }) { const saveConfig = useSavePluginConfig(); + const testConfig = useTestPluginConfig(); const saveAuthBinding = useSavePluginAuthBinding(); const saveTaskBinding = useSavePluginTaskBinding(); const capabilities = installation.capabilities ?? []; @@ -508,9 +544,26 @@ function ConfigureDialog({ key={schema.key} schema={schema} value={globalConfigs.find((entry) => entry.key === schema.key)?.value} + configuredSecrets={ + globalConfigs.find((entry) => entry.key === schema.key)?.configured_secrets + } isSaving={saveConfig.isPending} - onSave={(key, nextValue) => - saveConfig.mutate({ id: installation.id, body: { key, value: nextValue } }) + isTesting={testConfig.isPending} + onTest={(key, nextValue, clearSecrets) => + testConfig.mutateAsync({ + id: installation.id, + body: { key, value: nextValue, clear_secrets: clearSecrets }, + }) + } + onSave={(key, nextValue, clearSecrets) => + saveConfig.mutate({ + id: installation.id, + body: { + key, + value: nextValue, + clear_secrets: clearSecrets, + }, + }) } /> ))} @@ -530,6 +583,10 @@ function ConfigureDialog({
+

+ Auth-provider bindings are registered at server startup. Saved changes require a + restart. +

{authCapabilities.map((capability, index) => { const binding = authBindings.find((e) => e.capability_id === capability.id); return ( @@ -545,6 +602,7 @@ function ConfigureDialog({
saveAuthBinding.mutate({ id: installation.id, @@ -577,6 +635,10 @@ function ConfigureDialog({
+

+ Enable or disable each declared task binding. Task registration is rebuilt at + server startup, so saved changes require a restart. +

{taskCapabilities.map((capability) => { const binding = taskBindings.find((e) => e.capability_id === capability.id); return ( @@ -589,23 +651,24 @@ function ConfigureDialog({ {capability.display_name || capability.id}

{capability.id}

+

+ Trigger: {JSON.stringify(binding?.trigger ?? { type: "startup" })} +

- + />
); })} diff --git a/web/src/pages/AdminRecommendations.test.tsx b/web/src/pages/AdminRecommendations.test.tsx index 069ea1d7..19617e7d 100644 --- a/web/src/pages/AdminRecommendations.test.tsx +++ b/web/src/pages/AdminRecommendations.test.tsx @@ -23,7 +23,7 @@ vi.mock("@/hooks/queries/admin/settings", () => ({ isPending: false, mutateAsync: (...args: unknown[]) => mocks.checkConnectionMutateAsync(...args), }), - useUpdateServerSetting: () => ({ + useUpdateServerSettings: () => ({ isPending: false, mutate: (...args: unknown[]) => mocks.updateMutate(...args), mutateAsync: (...args: unknown[]) => mocks.updateMutateAsync(...args), @@ -125,7 +125,7 @@ describe("AdminRecommendations", () => { success: true, message: "Embedding connection successful.", }); - mocks.updateMutateAsync.mockResolvedValue(undefined); + mocks.updateMutateAsync.mockResolvedValue({ values: {}, restart_required: true }); }); afterEach(async () => { @@ -142,24 +142,22 @@ describe("AdminRecommendations", () => { } it("applies a provider preset to the embedding settings", async () => { + mocks.updateMutateAsync.mockResolvedValueOnce({ + values: { + "recommendations.embedding_base_url": "https://generativelanguage.googleapis.com/canonical", + "recommendations.embedding_model": "canonical-gemini-model", + }, + restart_required: true, + }); await render(); await click(findButton(container, "Gemini")); - expect(mocks.updateMutateAsync.mock.calls).toEqual([ - [ - { - key: "recommendations.embedding_base_url", - value: "https://generativelanguage.googleapis.com", - }, - ], - [ - { - key: "recommendations.embedding_model", - value: "gemini-embedding-001", - }, - ], - ]); + expect(mocks.updateMutateAsync).toHaveBeenCalledOnce(); + expect(mocks.updateMutateAsync).toHaveBeenCalledWith({ + "recommendations.embedding_base_url": "https://generativelanguage.googleapis.com", + "recommendations.embedding_model": "gemini-embedding-001", + }); const baseUrlInput = container.querySelector( 'input[id="recommendations.embedding_base_url"]', @@ -168,9 +166,8 @@ describe("AdminRecommendations", () => { 'input[id="recommendations.embedding_model"]', ); - expect(baseUrlInput?.value).toBe("https://generativelanguage.googleapis.com"); - expect(modelInput?.value).toBe("gemini-embedding-001"); - expect(findButton(container, "Gemini")?.getAttribute("aria-pressed")).toBe("true"); + expect(baseUrlInput?.value).toBe("https://generativelanguage.googleapis.com/canonical"); + expect(modelInput?.value).toBe("canonical-gemini-model"); }); it("checks the current unsaved embedding draft", async () => { diff --git a/web/src/pages/AdminRecommendations.tsx b/web/src/pages/AdminRecommendations.tsx index 8d02113b..edd77f73 100644 --- a/web/src/pages/AdminRecommendations.tsx +++ b/web/src/pages/AdminRecommendations.tsx @@ -11,7 +11,7 @@ import { ConnectionCheckAction } from "@/components/admin/ConnectionCheckAction" import { useAdminServerSettings, useCheckAdminSettingsConnection, - useUpdateServerSetting, + useUpdateServerSettings, useAdminSensitiveStatus, } from "@/hooks/queries/admin/settings"; import { @@ -75,6 +75,7 @@ function RecSettingField({ }: RecSettingFieldProps) { const { key, label, type, hint, defaultValue } = field; const effectiveServerValue = serverValue || defaultValue || ""; + const [confirmClear, setConfirmClear] = useState(false); if (type === "toggle") { const checked = serverValue === "true"; @@ -119,6 +120,46 @@ function RecSettingField({ disabled={isPending} className="max-w-md" /> + {isConfigured && !confirmClear && ( + + )} + {confirmClear && ( +
+ + Remove this credential from the server? + + + +
+ )} {hint &&

{hint}

}
); @@ -291,7 +332,7 @@ function RecEmbeddingLockCard({ export default function AdminRecommendations() { const { data: settings, isLoading } = useAdminServerSettings(); const { data: sensitiveData } = useAdminSensitiveStatus(); - const updateSetting = useUpdateServerSetting(); + const updateSettings = useUpdateServerSettings(); const checkConnection = useCheckAdminSettingsConnection(); const { data: status } = useRecommendationsStatus(); @@ -337,26 +378,31 @@ export default function AdminRecommendations() { async function handleCommit(key: string, value: string) { setLocalValues((prev) => ({ ...prev, [key]: value })); try { - await updateSetting.mutateAsync({ key, value }); + const result = await updateSettings.mutateAsync({ [key]: value }); + const field = getAllRecommendationFields().find((candidate) => candidate.key === key); + setLocalValues((prev) => ({ + ...prev, + [key]: field?.type === "password" ? "" : (result.values[key] ?? value), + })); setDirtyKeys((prev) => { const next = new Set(prev); next.delete(key); return next; }); - setRestartRequired(true); + setRestartRequired((current) => current || result.restart_required); } catch { - // useUpdateServerSetting already reports failures. + // useUpdateServerSettings already reports failures. } } function handleToggle(key: string, checked: boolean) { setConnectionResult(null); setLocalValues((prev) => ({ ...prev, [key]: checked ? "true" : "false" })); - updateSetting.mutate( - { key, value: checked ? "true" : "false" }, + updateSettings.mutate( + { [key]: checked ? "true" : "false" }, { - onSuccess: () => { - setRestartRequired(true); + onSuccess: (result) => { + setRestartRequired((current) => current || result.restart_required); }, }, ); @@ -364,26 +410,26 @@ export default function AdminRecommendations() { async function applyEmbeddingPreset(preset: RecommendationProviderPreset) { try { - await updateSetting.mutateAsync({ key: EMBEDDING_BASE_URL_KEY, value: preset.baseUrl }); - setLocalValues((prev) => ({ ...prev, [EMBEDDING_BASE_URL_KEY]: preset.baseUrl })); + const result = await updateSettings.mutateAsync({ + [EMBEDDING_BASE_URL_KEY]: preset.baseUrl, + [EMBEDDING_MODEL_KEY]: preset.model, + }); + setLocalValues((prev) => ({ + ...prev, + [EMBEDDING_BASE_URL_KEY]: result.values[EMBEDDING_BASE_URL_KEY] ?? preset.baseUrl, + [EMBEDDING_MODEL_KEY]: result.values[EMBEDDING_MODEL_KEY] ?? preset.model, + })); setDirtyKeys((prev) => { const next = new Set(prev); next.delete(EMBEDDING_BASE_URL_KEY); - return next; - }); - - await updateSetting.mutateAsync({ key: EMBEDDING_MODEL_KEY, value: preset.model }); - setLocalValues((prev) => ({ ...prev, [EMBEDDING_MODEL_KEY]: preset.model })); - setDirtyKeys((prev) => { - const next = new Set(prev); next.delete(EMBEDDING_MODEL_KEY); return next; }); - setRestartRequired(true); + setRestartRequired((current) => current || result.restart_required); setConnectionResult(null); } catch { - // useUpdateServerSetting already reports failures. + // useUpdateServerSettings already reports failures. } } @@ -540,7 +586,7 @@ export default function AdminRecommendations() { type="button" aria-pressed={selected} onClick={() => void applyEmbeddingPreset(preset)} - disabled={updateSetting.isPending} + disabled={updateSettings.isPending} className={`min-w-[8.5rem] rounded-md border px-3 py-2 text-left transition-colors ${ selected ? "border-foreground/20 bg-foreground/10 text-foreground" @@ -571,7 +617,7 @@ export default function AdminRecommendations() { serverValue={serverSettings[field.key] ?? ""} localValues={localValues} sensitiveConfigured={sensitiveConfigured} - isPending={updateSetting.isPending} + isPending={updateSettings.isPending} onLocalChange={handleLocalChange} onCommit={(key, value) => void handleCommit(key, value)} onToggle={handleToggle} @@ -583,7 +629,7 @@ export default function AdminRecommendations() { onClick={() => handleCheckConnection(serverSettings)} result={connectionResult} isPending={checkConnection.isPending} - disabled={updateSetting.isPending} + disabled={updateSettings.isPending} />
) : null} diff --git a/web/src/pages/admin-settings/AIServicesSettings.tsx b/web/src/pages/admin-settings/AIServicesSettings.tsx index 5da9bd5b..24aac8cc 100644 --- a/web/src/pages/admin-settings/AIServicesSettings.tsx +++ b/web/src/pages/admin-settings/AIServicesSettings.tsx @@ -3,12 +3,14 @@ import { toast } from "sonner"; import { useAdminSensitiveStatus, useAdminServerSettings, - useUpdateServerSetting, + useUpdateServerSettings, } from "@/hooks/queries/admin/settings"; +import { AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { QUOTA_PERIODS, QUOTA_PERIOD_WINDOW_LABELS } from "@/lib/quotaPeriods"; import { CredentialStatus } from "./CredentialStatus"; +import { RestartServerButton } from "./RestartServerButton"; import { SettingField } from "./SettingField"; // Connection settings live under the ai.* keys; reads fall back to the legacy @@ -79,7 +81,7 @@ const TRANSCRIPTION_PRESETS: { function AIConnectionCard() { const { data: settings } = useAdminServerSettings(); const { data: sensitive } = useAdminSensitiveStatus(); - const updateSetting = useUpdateServerSetting(); + const updateSettings = useUpdateServerSettings(); const configuredKeys = new Set(sensitive?.configured ?? []); const apiKeyConfigured = @@ -93,6 +95,7 @@ function AIConnectionCard() { const [asrBaseUrl, setAsrBaseUrl] = useState(""); const [asrApiKey, setAsrApiKey] = useState(""); const [maxConcurrent, setMaxConcurrent] = useState("2"); + const [restartRequired, setRestartRequired] = useState(false); useEffect(() => { if (!settings) return; @@ -107,7 +110,7 @@ function AIConnectionCard() { ); }, [settings]); - function save() { + async function save() { const trimmedBaseUrl = baseUrl.trim(); const trimmedChatModel = chatModel.trim(); const parsedMaxConcurrent = Number.parseInt(maxConcurrent, 10); @@ -127,30 +130,42 @@ function AIConnectionCard() { return; } - const updates = [ - updateSetting.mutateAsync({ key: "ai.base_url", value: trimmedBaseUrl }), - updateSetting.mutateAsync({ key: "ai.chat_model", value: trimmedChatModel }), - updateSetting.mutateAsync({ key: "ai.asr_model", value: asrModel.trim() }), - updateSetting.mutateAsync({ key: "ai.asr_base_url", value: asrBaseUrl.trim() }), - updateSetting.mutateAsync({ - key: "ai.max_concurrent_jobs", - value: String(parsedMaxConcurrent), - }), - ]; + const candidates: Record = { + "ai.base_url": trimmedBaseUrl, + "ai.chat_model": trimmedChatModel, + "ai.asr_model": asrModel.trim(), + "ai.asr_base_url": asrBaseUrl.trim(), + "ai.max_concurrent_jobs": String(parsedMaxConcurrent), + }; + const updates = Object.fromEntries( + Object.entries(candidates).filter(([key, value]) => settings?.[key] !== value), + ); if (apiKey.trim() !== "") { - updates.push(updateSetting.mutateAsync({ key: "ai.api_key", value: apiKey })); + updates["ai.api_key"] = apiKey; } if (asrApiKey.trim() !== "") { - updates.push(updateSetting.mutateAsync({ key: "ai.asr_api_key", value: asrApiKey })); + updates["ai.asr_api_key"] = asrApiKey; } - void Promise.all(updates).then(() => { + if (Object.keys(updates).length === 0) { + toast.info("No endpoint settings changed."); + return; + } + try { + const result = await updateSettings.mutateAsync(updates); setApiKey(""); setAsrApiKey(""); - }); + setRestartRequired((current) => current || result.restart_required); + toast.success("AI endpoint settings saved"); + } catch { + // The mutation reports the API error. + } } return ( -
+

Endpoint

@@ -243,20 +258,30 @@ function AIConnectionCard() { hint="One shared cap across subtitle translation, transcription, and description translation." />
-

- Changes take effect after a server restart. + Endpoint, model, and credential changes apply live. Changing the concurrency cap requires + a restart.

+ {restartRequired && ( +
+ + + Restart required to resize the AI job pool. + + +
+ )}
-
+
); } function AIFeaturesCard() { const { data: settings } = useAdminServerSettings(); - const updateSetting = useUpdateServerSetting(); + const updateSettings = useUpdateServerSettings(); const [subtitleTranslate, setSubtitleTranslate] = useState("false"); const [transcribe, setTranscribe] = useState("false"); @@ -281,7 +306,7 @@ function AIFeaturesCard() { setTranscribeQuotaPeriod(settings["subtitle_ai.transcribe_quota_period"] ?? "day"); }, [settings]); - function save() { + async function save() { const parsedBatch = Number.parseInt(batchSize, 10); const parsedNeighbors = Number.parseInt(contextNeighbors, 10); if (!Number.isInteger(parsedBatch) || parsedBatch < 1) { @@ -306,33 +331,37 @@ function AIFeaturesCard() { toast.error("Transcription limit must be zero (unlimited) or a positive whole number."); return; } - void Promise.all([ - updateSetting.mutateAsync({ key: "subtitle_ai.enabled", value: subtitleTranslate }), - updateSetting.mutateAsync({ key: "subtitle_ai.transcribe_enabled", value: transcribe }), - updateSetting.mutateAsync({ key: "metadata_ai.enabled", value: metadataTranslate }), - updateSetting.mutateAsync({ key: "metadata_ai.on_view", value: onView }), - updateSetting.mutateAsync({ key: "subtitle_ai.batch_size", value: String(parsedBatch) }), - updateSetting.mutateAsync({ - key: "subtitle_ai.context_neighbors", - value: String(parsedNeighbors), - }), - updateSetting.mutateAsync({ - key: "subtitle_ai.asr_chunk_seconds", - value: String(parsedChunkSeconds), - }), - updateSetting.mutateAsync({ - key: "subtitle_ai.transcribe_quota_jobs", - value: String(parsedQuotaJobs), - }), - updateSetting.mutateAsync({ - key: "subtitle_ai.transcribe_quota_period", - value: transcribeQuotaPeriod, - }), - ]); + const candidates: Record = { + "subtitle_ai.enabled": subtitleTranslate, + "subtitle_ai.transcribe_enabled": transcribe, + "metadata_ai.enabled": metadataTranslate, + "metadata_ai.on_view": onView, + "subtitle_ai.batch_size": String(parsedBatch), + "subtitle_ai.context_neighbors": String(parsedNeighbors), + "subtitle_ai.asr_chunk_seconds": String(parsedChunkSeconds), + "subtitle_ai.transcribe_quota_jobs": String(parsedQuotaJobs), + "subtitle_ai.transcribe_quota_period": transcribeQuotaPeriod, + }; + const updates = Object.fromEntries( + Object.entries(candidates).filter(([key, value]) => settings?.[key] !== value), + ); + if (Object.keys(updates).length === 0) { + toast.info("No feature settings changed."); + return; + } + try { + await updateSettings.mutateAsync(updates); + toast.success("AI feature settings saved"); + } catch { + // The mutation reports the API error. + } } return ( -
+

Features

@@ -413,14 +442,12 @@ function AIFeaturesCard() { hint="Rolling window the transcription limit counts against." />

- -

- Changes take effect after a server restart. -

+

Feature changes apply live.

-
+
); } diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx index 0937851a..21a03e3e 100644 --- a/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx +++ b/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx @@ -3,16 +3,33 @@ import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import AdminSettingsLayout from "./AdminSettingsLayout"; +const mocks = vi.hoisted(() => ({ + useAdminServerStatus: vi.fn(), +})); + // The layout only needs the active tab's component to render; a loading form // keeps every settings page on its skeleton state so no other hooks fire. vi.mock("@/hooks/useSettingsForm", () => ({ - useSettingsForm: () => ({ isLoading: true }), + useSettingsForm: () => ({ + isLoading: true, + sensitiveConfigured: [], + sensitiveManagedByEnv: [], + }), })); +vi.mock("@/hooks/queries/admin/settings", async (importOriginal) => ({ + ...(await importOriginal()), + useAdminServerStatus: (...args: unknown[]) => mocks.useAdminServerStatus(...args), +})); + +beforeEach(() => { + mocks.useAdminServerStatus.mockReturnValue({ data: { restart_required: false } }); +}); + function renderLayout(search = "") { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -51,9 +68,11 @@ describe("AdminSettingsLayout", () => { for (const label of [ "General", + "Branding", "Theming", "Card Overlays", "Scanner & Matcher", + "Search", "Intro Markers", "Subtitles", "AI Services", @@ -80,6 +99,14 @@ describe("AdminSettingsLayout", () => { expect(markup).toBe(renderLayout("?tab=general")); }); + it("surfaces durable restart-required state above the active tab", () => { + mocks.useAdminServerStatus.mockReturnValue({ data: { restart_required: true } }); + + const markup = renderLayout(); + + expect(markup).toContain("Server restart required for saved settings to take effect."); + }); + it("resolves the legacy jellyfin tab alias to Compatibility Proxies", () => { const withAlias = renderLayout("?tab=jellyfin"); const direct = renderLayout("?tab=compatibility-proxies"); diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.tsx index c905b862..09617f22 100644 --- a/web/src/pages/admin-settings/AdminSettingsLayout.tsx +++ b/web/src/pages/admin-settings/AdminSettingsLayout.tsx @@ -1,4 +1,5 @@ import { useMemo, useState, type ComponentType } from "react"; +import { AlertTriangle } from "lucide-react"; import { useSearchParams } from "react-router"; import { SideNavItem, SideNavSection } from "@/components/SideNav"; @@ -13,6 +14,7 @@ import { type AdminSettingsSearchItem, } from "@/lib/adminSettingsSearch"; import { cn } from "@/lib/utils"; +import { useAdminServerStatus } from "@/hooks/queries/admin/settings"; import EmailSettings from "./EmailSettings"; import NotificationsAdminSettings from "./NotificationsAdminSettings"; @@ -34,6 +36,7 @@ import LogRetentionSettings from "./LogRetentionSettings"; import ThemeSettings from "./ThemeSettings"; import BrandingSettings from "./BrandingSettings"; import OverlaySettings from "./OverlaySettings"; +import { RestartServerButton } from "./RestartServerButton"; interface SettingsNav extends AdminSettingsSearchItem { component: ComponentType; @@ -88,6 +91,7 @@ const SETTINGS_NAV: SettingsNav[] = ADMIN_SETTINGS_NAV.map((item) => ({ export default function AdminSettingsLayout() { const [searchParams, setSearchParams] = useSearchParams(); const [settingsSearch, setSettingsSearch] = useState(""); + const { data: serverStatus } = useAdminServerStatus(); const rawActiveId = searchParams.get("tab") || "general"; const activeId = rawActiveId === "jellyfin" ? "compatibility-proxies" : rawActiveId; const filteredSettingsGroups = useMemo( @@ -112,7 +116,8 @@ export default function AdminSettingsLayout() {

Settings

- Configure server-wide settings. Most changes require a server restart to take effect. + Configure server-wide settings. Most changes apply live; startup-bound fields show a + restart warning after they are saved.

+ {serverStatus?.restart_required && ( +
+
+ + Server restart required for saved settings to take effect. +
+ +
+ )} +
{/* Mobile: horizontal scrolling pill bar */}
- {!s3Configured && ( + {!assetStorageAvailable && (

- Image uploads require S3 object storage. Configure a public bucket in{" "} - Storage settings to enable - custom logos, favicon, and login background. + {s3Configured ? ( + <> + The public bucket is saved, but object storage is not active in this process + yet. Restart the server to enable image uploads. + + ) : ( + <> + Image uploads require S3 object storage. Configure a public bucket in{" "} + Storage settings, then + restart the server. + + )}

)} @@ -140,7 +150,7 @@ export default function BrandingSettings() { kind="wordmark" currentUrl={branding.wordmarkUrl} accept={IMAGE_ACCEPT} - enabled={s3Configured} + enabled={assetStorageAvailable} preview="wide" />
diff --git a/web/src/pages/admin-settings/CompatibilityProxiesSettings.test.tsx b/web/src/pages/admin-settings/CompatibilityProxiesSettings.test.tsx index ed325c92..5e36a872 100644 --- a/web/src/pages/admin-settings/CompatibilityProxiesSettings.test.tsx +++ b/web/src/pages/admin-settings/CompatibilityProxiesSettings.test.tsx @@ -9,6 +9,22 @@ vi.mock("@/hooks/useSettingsForm", () => ({ useSettingsForm: (...args: unknown[]) => useSettingsFormMock(...args), })); +vi.mock("@/hooks/queries/admin/settings", () => ({ + useJellyfinCompatStatus: () => ({ + isLoading: false, + data: { + enabled: false, + web_enabled: false, + api_state: "stopped", + web_state: "missing", + prerequisites: [], + }, + }), + useInstallJellyfinCompatWeb: () => ({ mutate: vi.fn(), isPending: false }), + useRemoveJellyfinCompatWeb: () => ({ mutate: vi.fn(), isPending: false }), + useUpdateJellyfinCompatSettings: () => ({ mutate: vi.fn(), isPending: false }), +})); + describe("CompatibilityProxiesSettings", () => { it("shows Jellyfin and Audiobookshelf proxy settings", () => { useSettingsFormMock.mockReturnValue({ @@ -20,6 +36,7 @@ describe("CompatibilityProxiesSettings", () => { }, setValue: vi.fn(), dirtyCount: 0, + dirtyKeys: [], save: vi.fn(), discard: vi.fn(), isSaving: false, diff --git a/web/src/pages/admin-settings/DatabaseSettings.tsx b/web/src/pages/admin-settings/DatabaseSettings.tsx index 9f16c593..84371b6d 100644 --- a/web/src/pages/admin-settings/DatabaseSettings.tsx +++ b/web/src/pages/admin-settings/DatabaseSettings.tsx @@ -16,8 +16,6 @@ const KEYS = [ "userdb.backend", "userdb.pool_max_open", "userdb.idle_timeout", - "userdb.litestream_sync", - "userdb.stale_grace_seconds", ]; export default function DatabaseSettings() { @@ -97,6 +95,7 @@ export default function DatabaseSettings() { onChange={(value) => { if (value === "true") { setRedisEnabledOverride(true); + form.resetValue("redis.url"); return; } setRedisEnabledOverride(false); @@ -128,36 +127,31 @@ export default function DatabaseSettings() { form.setValue("userdb.backend", v)} /> - form.setValue("userdb.pool_max_open", v)} - /> - form.setValue("userdb.idle_timeout", v)} - /> - form.setValue("userdb.litestream_sync", v)} - /> - form.setValue("userdb.stale_grace_seconds", v)} - /> + {form.getValue("userdb.backend") === "sqlite" && ( + <> + form.setValue("userdb.pool_max_open", v)} + /> + form.setValue("userdb.idle_timeout", v)} + /> + + )}
diff --git a/web/src/pages/admin-settings/DownloadSettings.tsx b/web/src/pages/admin-settings/DownloadSettings.tsx index e9b39c8c..b5304ee0 100644 --- a/web/src/pages/admin-settings/DownloadSettings.tsx +++ b/web/src/pages/admin-settings/DownloadSettings.tsx @@ -88,7 +88,7 @@ export default function DownloadSettings() { /> form.setValue("download.artifact_dir", v)} /> diff --git a/web/src/pages/admin-settings/EmailSettings.tsx b/web/src/pages/admin-settings/EmailSettings.tsx index 33063481..4dad4e10 100644 --- a/web/src/pages/admin-settings/EmailSettings.tsx +++ b/web/src/pages/admin-settings/EmailSettings.tsx @@ -96,8 +96,8 @@ export default function EmailSettings() {

Email

- Outbound email via your own SMTP server. Used by features that send mail — notification - emails, account flows — once they are enabled. + Outbound email via your own SMTP server. Used for notification-address verification and + notification digests once those features are enabled.

diff --git a/web/src/pages/admin-settings/GeneralSettings.tsx b/web/src/pages/admin-settings/GeneralSettings.tsx index 705d2f5f..f76b3598 100644 --- a/web/src/pages/admin-settings/GeneralSettings.tsx +++ b/web/src/pages/admin-settings/GeneralSettings.tsx @@ -15,6 +15,7 @@ const KEYS = [ export default function GeneralSettings() { const form = useSettingsForm({ keys: useMemo(() => KEYS, []) }); + const trustedProxiesManaged = form.sensitiveManagedByEnv.includes("clientip.trusted_proxies"); if (form.isLoading) return ( @@ -73,8 +74,8 @@ export default function GeneralSettings() { ]} /> form.setValue("server.log_quiet", v)} /> @@ -84,11 +85,15 @@ export default function GeneralSettings() { form.setValue("clientip.trusted_proxies", v)} + disabled={trustedProxiesManaged} />

Choosing trusted proxy ranges

diff --git a/web/src/pages/admin-settings/IntegrationsSettings.tsx b/web/src/pages/admin-settings/IntegrationsSettings.tsx index b257f99c..3136c382 100644 --- a/web/src/pages/admin-settings/IntegrationsSettings.tsx +++ b/web/src/pages/admin-settings/IntegrationsSettings.tsx @@ -1,5 +1,12 @@ import { useState } from "react"; -import { useAdminSensitiveStatus, useUpdateServerSetting } from "@/hooks/queries/admin/settings"; +import { toast } from "sonner"; +import type { ConnectionCheckResponse } from "@/api/types"; +import { ConnectionCheckAction } from "@/components/admin/ConnectionCheckAction"; +import { + useAdminSensitiveStatus, + useCheckAdminSettingsConnection, + useUpdateServerSettings, +} from "@/hooks/queries/admin/settings"; import { Button } from "@/components/ui/button"; import { CredentialStatus } from "./CredentialStatus"; @@ -7,19 +14,64 @@ import { SettingField } from "./SettingField"; function MDBListCredentialCard() { const { data: sensitive } = useAdminSensitiveStatus(); - const updateSetting = useUpdateServerSetting(); + const updateSettings = useUpdateServerSettings(); + const checkConnection = useCheckAdminSettingsConnection(); const [apiKey, setApiKey] = useState(""); + const [confirmClear, setConfirmClear] = useState(false); + const [connectionResult, setConnectionResult] = useState(null); const configured = new Set(sensitive?.configured ?? []).has("mdblist.api_key"); - function save() { - if (apiKey.trim() === "") return; - void updateSetting.mutateAsync({ key: "mdblist.api_key", value: apiKey }).then(() => { + async function save() { + if (apiKey.trim() === "") { + toast.info("No MDBList API key change to save."); + return; + } + try { + await updateSettings.mutateAsync({ "mdblist.api_key": apiKey }); setApiKey(""); - }); + setConnectionResult(null); + toast.success("MDBList API key saved"); + } catch { + // The mutation reports the API error. + } + } + + async function clearKey() { + try { + await updateSettings.mutateAsync({ "mdblist.api_key": "" }); + setApiKey(""); + setConfirmClear(false); + setConnectionResult(null); + toast.success("MDBList API key cleared"); + } catch { + // The mutation reports the API error. + } + } + + async function testKey() { + try { + setConnectionResult( + await checkConnection.mutateAsync({ + kind: "mdblist", + body: { + values: { "mdblist.api_key": apiKey }, + dirty_keys: apiKey.trim() === "" ? [] : ["mdblist.api_key"], + }, + }), + ); + } catch (error) { + setConnectionResult({ + success: false, + message: error instanceof Error ? error.message : "Connection check failed.", + }); + } } return ( -
+

MDBList

@@ -47,10 +99,42 @@ function MDBListCredentialCard() { sensitiveConfigured={configured} hint="Leave blank to keep the current value." /> - -
+
+ + {configured && !confirmClear && ( + + )} + {confirmClear && ( + <> + Disable MDBList discovery? + + + + )} +
+ void testKey()} + result={connectionResult} + isPending={checkConnection.isPending} + disabled={updateSettings.isPending || (!configured && apiKey.trim() === "")} + /> +

+ Test Connection uses the key entered above, or the saved key when the field is blank. +

+
); } diff --git a/web/src/pages/admin-settings/IntroSettings.tsx b/web/src/pages/admin-settings/IntroSettings.tsx index f7ba90cf..f51c0e25 100644 --- a/web/src/pages/admin-settings/IntroSettings.tsx +++ b/web/src/pages/admin-settings/IntroSettings.tsx @@ -133,7 +133,10 @@ function ProviderSettingsForm({ provider }: { provider: MarkerProviderConfig }) const validation = validateProvider.data; return ( -
+

{displayName}

@@ -266,7 +269,7 @@ function ProviderSettingsForm({ provider }: { provider: MarkerProviderConfig }) {updateProvider.isPending ? "Saving..." : "Save Provider Settings"}

-
+ ); } diff --git a/web/src/pages/admin-settings/LogRetentionSettings.tsx b/web/src/pages/admin-settings/LogRetentionSettings.tsx index 2163b0f9..a544108a 100644 --- a/web/src/pages/admin-settings/LogRetentionSettings.tsx +++ b/web/src/pages/admin-settings/LogRetentionSettings.tsx @@ -10,7 +10,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { useAdminServerSettings, useUpdateServerSetting } from "@/hooks/queries/admin/settings"; +import { useAdminServerSettings, useUpdateServerSettings } from "@/hooks/queries/admin/settings"; import { FieldGroup } from "./FieldGroup"; import { SaveBar } from "./SaveBar"; @@ -55,7 +55,7 @@ function createBucketRow(policy?: Partial, fallbackID export default function LogRetentionSettings() { const { data: settings, isLoading } = useAdminServerSettings(); - const updateSetting = useUpdateServerSetting(); + const updateSettings = useUpdateServerSettings(); const [localValues, setLocalValues] = useState>({}); const [bucketRows, setBucketRows] = useState([]); @@ -160,19 +160,20 @@ export default function LogRetentionSettings() { async function save() { setSaveError(""); - const requests = Array.from(dirty).map((key) => { - const value = + const values = Object.fromEntries( + Array.from(dirty).map((key) => [ + key, key === OPSLOG_BUCKET_POLICIES_KEY ? serializeBucketPolicies(effectiveBucketRows) - : (effectiveLocalValues[key] ?? ""); - return updateSetting.mutateAsync({ key, value }); - }); + : (effectiveLocalValues[key] ?? ""), + ]), + ); try { - await Promise.all(requests); + const result = await updateSettings.mutateAsync(values); setDirty(new Set()); - setRestartRequired(true); + setRestartRequired((current) => current || result.restart_required); } catch { - setSaveError("Failed to save some settings. Please try again."); + setSaveError("Failed to save settings. No changes were applied."); } } @@ -207,7 +208,7 @@ export default function LogRetentionSettings() {

-
+
setValue(POLICY_DECISION_LOG_VERBOSITY_KEY, value)} options={[ @@ -395,17 +396,17 @@ export default function LogRetentionSettings() {
Matching rows are pruned oldest-first when they exceed the bucket rule. Global caps - still apply afterward, so noisy buckets cannot crowd out playback or error logs. + then prune the oldest rows across every remaining bucket.
-
+
diff --git a/web/src/pages/admin-settings/NotificationsAdminSettings.test.tsx b/web/src/pages/admin-settings/NotificationsAdminSettings.test.tsx index 8cc5ef23..e3c9ac8c 100644 --- a/web/src/pages/admin-settings/NotificationsAdminSettings.test.tsx +++ b/web/src/pages/admin-settings/NotificationsAdminSettings.test.tsx @@ -27,6 +27,7 @@ function makeForm() { case "notifications.ui_enabled": case "notifications.web_push_enabled": case "notifications.apple_push_delivery_enabled": + case "notifications.android_push_delivery_enabled": return "true"; case "notifications.push_relay_url": return "https://push.siloserver.org"; @@ -79,6 +80,7 @@ describe("NotificationsAdminSettings", () => { expect(useSettingsFormMock).toHaveBeenCalledWith({ keys: expect.arrayContaining([ "notifications.apple_push_delivery_enabled", + "notifications.android_push_delivery_enabled", "notifications.push_relay_deployment_id", "notifications.push_relay_expires_at", "notifications.push_relay_key_prefix", @@ -100,13 +102,14 @@ describe("NotificationsAdminSettings", () => { render(renderPage()); expect(screen.getByText("Silo Push Relay")).toBeInTheDocument(); - expect(screen.getByText(/Mobile push delivery through Silo's relay/)).toBeInTheDocument(); - expect(screen.getByText(/Android support will use the same relay/)).toBeInTheDocument(); + expect(screen.getByText(/delivered by APNs or FCM/)).toBeInTheDocument(); expect(screen.getByText("Relay configured")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: /Silo Push Relay/ })); expect(screen.getByText("Privacy disclosure")).toBeInTheDocument(); + expect(screen.getByText("Apple Push (APNs)")).toBeInTheDocument(); + expect(screen.getByText("Android Push (FCM)")).toBeInTheDocument(); expect(screen.getByText(/content-free request to Silo's push relay/)).toBeInTheDocument(); expect(screen.getByText(/does not receive notification titles/)).toBeInTheDocument(); expect(screen.getByText(/fetches private content directly/)).toBeInTheDocument(); diff --git a/web/src/pages/admin-settings/NotificationsAdminSettings.tsx b/web/src/pages/admin-settings/NotificationsAdminSettings.tsx index 89a1a57f..3812fb32 100644 --- a/web/src/pages/admin-settings/NotificationsAdminSettings.tsx +++ b/web/src/pages/admin-settings/NotificationsAdminSettings.tsx @@ -27,6 +27,16 @@ import { Link } from "react-router"; import { toast } from "sonner"; import { api } from "@/api/client"; import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { adminKeys } from "@/hooks/queries/keys"; @@ -45,6 +55,7 @@ const KEYS = [ "notifications.webhooks_enabled", "notifications.web_push_enabled", "notifications.apple_push_delivery_enabled", + "notifications.android_push_delivery_enabled", // Relay lifecycle fields are read for status but are never edited through // the shared settings form; credential endpoints replace them atomically. "notifications.push_relay_url", @@ -431,6 +442,49 @@ function TestDiscordRow({ unsaved }: { unsaved: boolean }) { ); } +function ClearDiscordCredentialsRow({ + configured, + onClear, +}: { + configured: boolean; + onClear: () => void; +}) { + const [open, setOpen] = useState(false); + if (!configured) return null; + + return ( +
+ + + + + Clear all Discord credentials? + + This stages removal of the client ID, client secret, and bot token. Discord linking + and delivery stop after you save the settings form. + + + + Cancel + { + onClear(); + setOpen(false); + toast.info("Discord credential removal staged. Save changes to apply it."); + }} + > + Stage credential removal + + + + +
+ ); +} + function RegisterRelayRow({ relayURL, deploymentID, @@ -446,10 +500,11 @@ function RegisterRelayRow({ expiresAt: string; reregistrationRequired: boolean; urlEdited: boolean; - onRegistered: () => void; + onRegistered: (submittedRelayURL: string) => void; }) { const queryClient = useQueryClient(); const [pending, setPending] = useState(false); + const [confirmClear, setConfirmClear] = useState(false); const [result, setResult] = useState(null); const configured = deploymentID.trim() !== ""; @@ -489,7 +544,7 @@ function RegisterRelayRow({ queryKey: [...adminKeys.serverSettings(), "sensitive-status"] as const, }), ]); - onRegistered(); + onRegistered(relayURL); toast.success("Push relay registered"); } catch (error) { toast.error(error instanceof Error ? error.message : "Relay registration failed"); @@ -498,6 +553,27 @@ function RegisterRelayRow({ } }; + const clearRelay = async () => { + if (pending) return; + setPending(true); + setResult(null); + try { + await api("/admin/notifications/push/relay", { method: "DELETE" }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: adminKeys.serverSettings() }), + queryClient.invalidateQueries({ + queryKey: [...adminKeys.serverSettings(), "sensitive-status"] as const, + }), + ]); + setConfirmClear(false); + toast.success("Push relay credential cleared"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to clear relay credential"); + } finally { + setPending(false); + } + }; + return (
+ {configured && ( + + )}
{reregistrationRequired && (
@@ -540,26 +626,48 @@ function RegisterRelayRow({ {result.relay_request_id ? ` — relay ${result.relay_request_id}` : ""}
)} + + + + Clear the push relay credential? + + Mobile push delivery will stop until a relay is registered again. This clears the + local deployment identity and lets you select a different relay origin. + + + + Cancel + void clearRelay()} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {pending ? "Clearing..." : "Clear credential"} + + + + ); } -function ApplePushPrivacyDisclosure() { +function MobilePushPrivacyDisclosure() { return (
Privacy disclosure

If you enable push notifications, your Silo Server sends a content-free request to Silo's - push relay so Silo can deliver notifications through Apple Push Notification service. + push relay so Silo can deliver notifications through Apple Push Notification service or + Firebase Cloud Messaging.

The relay does not receive notification titles, message bodies, media names, user names, profile names, or your server URL. It does process technical metadata needed to deliver and operate the service, including an opaque deployment identifier, push delivery timing, request status, app topic, the IP address your self-hosted Silo Server uses to contact the - relay, and a hashed device push token. Apple may also process standard APNs delivery - metadata. + relay, and a hashed device push token. Apple or Google may also process standard push + delivery metadata for their platform.

Push notifications are generic; the app fetches private content directly from your Silo @@ -611,6 +719,8 @@ export default function NotificationsAdminSettings() { const serverChannelsOn = isOn("notifications.server_channels_enabled"); // Mobile push, Discord, and personal webhooks are opt-in (default off). const applePushOn = form.getValue("notifications.apple_push_delivery_enabled") === "true"; + const androidPushOn = form.getValue("notifications.android_push_delivery_enabled") === "true"; + const mobilePushOn = applePushOn || androidPushOn; const discordOn = form.getValue("notifications.discord_enabled") === "true"; const webhooksOn = form.getValue("notifications.webhooks_enabled") === "true"; @@ -645,7 +755,7 @@ export default function NotificationsAdminSettings() { const channelStates = [ uiOn, webPushOn, - applePushOn, + mobilePushOn, emailOn, discordOn, webhooksOn, @@ -755,21 +865,42 @@ export default function NotificationsAdminSettings() { { + form.setValue("notifications.apple_push_delivery_enabled", String(enabled)); + form.setValue("notifications.android_push_delivery_enabled", String(enabled)); + }} chips={ pushRelayReregistrationRequired ? ( Re-registration required ) : pushRelayAPIKeyReady ? ( Relay configured ) : ( - Relay registration required + Relay registration required ) } >

- + + + form.setValue("notifications.apple_push_delivery_enabled", value) + } + /> + + form.setValue("notifications.android_push_delivery_enabled", value) + } + /> setPushRelayURLDraft(null)} + onRegistered={(submittedRelayURL) => + setPushRelayURLDraft((currentDraft) => { + const currentURL = currentDraft ?? savedPushRelayURL; + return currentURL === submittedRelayURL ? null : currentDraft; + }) + } />
@@ -878,6 +1014,19 @@ export default function NotificationsAdminSettings() { onChange={(v) => form.setValue("discord.bot_token", v)} /> + + ["discord.client_secret", "discord.bot_token"].includes(key), + ) + } + onClear={() => { + form.setValue("discord.client_id", ""); + form.setValue("discord.client_secret", ""); + form.setValue("discord.bot_token", ""); + }} + />
Delivery
diff --git a/web/src/pages/admin-settings/RateLimitSettings.tsx b/web/src/pages/admin-settings/RateLimitSettings.tsx index 87266874..c953deb3 100644 --- a/web/src/pages/admin-settings/RateLimitSettings.tsx +++ b/web/src/pages/admin-settings/RateLimitSettings.tsx @@ -31,7 +31,7 @@ const DEFAULT_AUTH_ENDPOINT: RateLimitAuthEndpointConfig = { }; const DEFAULT_CONFIG: RateLimitConfig = { - enabled: false, + enabled: true, backend: "memory", global_requests_per_second: 1000, tiers: { @@ -45,6 +45,10 @@ const DEFAULT_CONFIG: RateLimitConfig = { login: { requests_per_minute: 20, burst: 10 }, signup: { requests_per_minute: 10, burst: 6 }, setup: { requests_per_minute: 10, burst: 6 }, + device_start: { requests_per_minute: 20, burst: 10 }, + device_lookup: { requests_per_minute: 60, burst: 20 }, + device_poll: { requests_per_minute: 120, burst: 30 }, + autoscan_webhook: { requests_per_minute: 60, burst: 30 }, }, }; @@ -57,6 +61,10 @@ const AUTH_ENDPOINT_LABELS: Record = { login: "Login", signup: "Signup", setup: "Setup", + device_start: "Device Authorization Start", + device_lookup: "Device Authorization Lookup", + device_poll: "Device Authorization Polling", + autoscan_webhook: "Autoscan Webhook", }; export default function RateLimitSettings() { @@ -77,11 +85,14 @@ export default function RateLimitSettings() { ip_requests_per_minute: serverConfig.ip_requests_per_minute ?? DEFAULT_CONFIG.ip_requests_per_minute, ip_burst: serverConfig.ip_burst ?? DEFAULT_CONFIG.ip_burst, - auth_endpoints: { - login: serverConfig.auth_endpoints?.login ?? DEFAULT_CONFIG.auth_endpoints.login!, - signup: serverConfig.auth_endpoints?.signup ?? DEFAULT_CONFIG.auth_endpoints.signup!, - setup: serverConfig.auth_endpoints?.setup ?? DEFAULT_CONFIG.auth_endpoints.setup!, - }, + auth_endpoints: Object.fromEntries( + Object.keys(AUTH_ENDPOINT_LABELS).map((endpoint) => [ + endpoint, + serverConfig.auth_endpoints?.[endpoint] ?? + DEFAULT_CONFIG.auth_endpoints[endpoint] ?? + DEFAULT_AUTH_ENDPOINT, + ]), + ), }; }, [serverConfig]); const hydratedKey = JSON.stringify(hydratedConfig); @@ -103,7 +114,7 @@ export default function RateLimitSettings() { function handleTierChange(tier: string, field: keyof RateLimitTierConfig, value: string) { const num = parseInt(value, 10); - if (isNaN(num) || num < 0) return; + if (isNaN(num) || num <= 0) return; updateConfigState((prev) => { const existing: RateLimitTierConfig = prev.tiers[tier] ?? DEFAULT_TIER; return { @@ -125,7 +136,7 @@ export default function RateLimitSettings() { value: string, ) { const num = parseInt(value, 10); - if (isNaN(num) || num < 0) return; + if (isNaN(num) || num <= 0) return; updateConfigState((prev) => { const existing: RateLimitAuthEndpointConfig = prev.auth_endpoints[endpoint] ?? DEFAULT_AUTH_ENDPOINT; @@ -162,11 +173,12 @@ export default function RateLimitSettings() {

Rate Limiting

- Configure request budgets for API keys, IPs, and authentication endpoints. + Configure request budgets for protected API routes, API keys, and public authentication or + Autoscan endpoints.

-
+
{pendingRestart && (
@@ -214,8 +226,8 @@ export default function RateLimitSettings() {

- Requires a restart to take effect. Redis is recommended for multi-instance - deployments. + Backend changes require a restart. Redis is recommended for multi-instance deployments + and must first be configured under Database.

@@ -240,7 +252,7 @@ export default function RateLimitSettings() { className="w-full sm:w-40" />

- Maximum requests per second across all clients combined. + Maximum requests per second across every route protected by the rate limiter.

@@ -248,7 +260,8 @@ export default function RateLimitSettings() {
Per-IP Limits

- Applied to all authenticated requests from a single IP address. + Shared across protected authenticated routes and the public auth/Autoscan endpoints for + one IP address.

@@ -365,7 +378,8 @@ export default function RateLimitSettings() {
Auth Endpoint Limits

- Per-IP limits for authentication endpoints to prevent brute-force attacks. + Per-IP limits for public authentication and Autoscan endpoints. These apply in addition + to the global and shared per-IP budgets above.

{Object.keys(AUTH_ENDPOINT_LABELS).map((endpoint) => { @@ -418,7 +432,7 @@ export default function RateLimitSettings() { {updateConfig.isPending ? "Saving..." : "Save Changes"}
-
+
); } diff --git a/web/src/pages/admin-settings/ServerNotificationChannels.tsx b/web/src/pages/admin-settings/ServerNotificationChannels.tsx index e8b9cd4b..f3cf9e4a 100644 --- a/web/src/pages/admin-settings/ServerNotificationChannels.tsx +++ b/web/src/pages/admin-settings/ServerNotificationChannels.tsx @@ -150,7 +150,7 @@ function ChannelFormDialog({ JSON. -
+
))} -
+
+ {credentialsConfigured && ( + + )} {testResult !== null && ( {testResult.success @@ -190,7 +231,42 @@ function SubtitleProviderCard({ config }: { config: SubtitleProviderConfig }) { )}
-
+

+ Test Connection uses the values currently entered above. Saving applies provider changes + live to new searches. +

+ + + + Clear {displayName} credentials? + + The provider will be disabled and removed from live subtitle searches immediately. + + + + Cancel + + updateProvider.mutate( + { provider: providerName, config: { enabled: false, clear_credentials: true } }, + { + onSuccess: () => { + setForm(defaultSubtitleFormState({ ...config, enabled: false })); + setTestResult(null); + setConfirmClear(false); + }, + }, + ) + } + > + {updateProvider.isPending ? "Clearing..." : "Clear and disable"} + + + + + ); } diff --git a/web/src/pages/admin-settings/ThemeSettings.test.tsx b/web/src/pages/admin-settings/ThemeSettings.test.tsx new file mode 100644 index 00000000..a4a634c4 --- /dev/null +++ b/web/src/pages/admin-settings/ThemeSettings.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + updateMutate: vi.fn(), +})); + +vi.mock("@/hooks/queries/admin/settings", () => ({ + useAdminServerSettings: () => ({ + data: { + "ui.admin_theme_vars": "{}", + "ui.admin_custom_css": "", + "theme.catalog_url": "https://themes.example.invalid/catalog.json", + }, + }), + useUpdateServerSetting: () => ({ + mutate: (...args: unknown[]) => mocks.updateMutate(...args), + }), +})); + +vi.mock("@/components/theme/TokenEditor", () => ({ + TokenEditor: ({ onSetVar }: { onSetVar: (token: "primary", value: string) => void }) => ( + <> + + + + ), +})); + +vi.mock("@/components/theme/RawCssEditor", () => ({ + RawCssEditor: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( +