Merge pull request #456 from Silo-Server/feat/admin-settings-contract

fix(admin): enforce settings contracts end to end
This commit is contained in:
Quick
2026-07-23 16:00:55 -04:00
committed by GitHub
87 changed files with 8971 additions and 819 deletions
+20 -3
View File
@@ -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,
})
+14 -9
View File
@@ -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.
+544 -34
View File
@@ -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
}
+20
View File
@@ -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) {
@@ -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(
&notifications.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],
)
}
}
@@ -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)
}
}
+78 -2
View File
@@ -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."}
@@ -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)
}
})
}
@@ -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)
}
})
}
}
+140 -12
View File
@@ -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":
@@ -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)
}
}
+19 -17
View File
@@ -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(),
})
}
+80 -10
View File
@@ -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 {
@@ -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)
}
})
}
}
+243 -65
View File
@@ -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
}
+218 -3
View File
@@ -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)
}
}
+73 -7
View File
@@ -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
}
@@ -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})
+32 -22
View File
@@ -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.
+103
View File
@@ -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)
}
}
+10 -3
View File
@@ -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)
@@ -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.
@@ -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
+99 -40
View File
@@ -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)
}
+608
View File
@@ -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
}
+292
View File
@@ -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)
}
}
+4
View File
@@ -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,
+42
View File
@@ -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
}
+1
View File
@@ -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+":")
}
+17
View File
@@ -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()
+7
View File
@@ -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
@@ -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
+370
View File
@@ -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
}
+83
View File
@@ -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)
}
}
+236 -9
View File
@@ -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 (
@@ -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")
}
}
+8
View File
@@ -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 {
+296 -3
View File
@@ -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
}
}
+35 -1
View File
@@ -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,
+505
View File
@@ -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) {
+44 -17
View File
@@ -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
}
}
+53
View File
@@ -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)
}
}
+51 -35
View File
@@ -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))
+102
View File
@@ -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)
}
}
+13
View File
@@ -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
+20
View File
@@ -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
}
+22
View File
@@ -3423,6 +3423,8 @@ export interface PluginAsset {
export interface PluginConfigValue {
key: string;
value: Record<string, unknown>;
/** 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<string, unknown>;
/** 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<string, string>;
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 {
@@ -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(
<PluginConfigForm
schema={schema}
value={{ region: "us-east" }}
configuredSecrets={["api_key"]}
onSave={onSave}
/>,
);
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(
<PluginConfigForm
schema={requiredSchema}
value={{ region: "us-east" }}
configuredSecrets={["api_key"]}
onSave={vi.fn()}
/>,
);
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(
<PluginConfigForm
schema={schema}
value={{ region: "us-east" }}
configuredSecrets={["api_key"]}
onSave={vi.fn()}
isSaving
/>,
);
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(
<PluginConfigForm
schema={schema}
value={{ region: "us-east" }}
configuredSecrets={["api_key"]}
onSave={vi.fn()}
onTest={onTest}
/>,
);
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",
]);
});
});
@@ -18,8 +18,13 @@ type PluginConfigValue = Record<string, unknown>;
type Props = {
schema: PluginConfigSchema;
value?: PluginConfigValue;
onSave: (key: string, value: PluginConfigValue) => void;
onTest?: (key: string, value: PluginConfigValue) => Promise<ConnectionCheckResponse>;
configuredSecrets?: string[];
onSave: (key: string, value: PluginConfigValue, clearSecrets: string[]) => void;
onTest?: (
key: string,
value: PluginConfigValue,
clearSecrets: string[],
) => Promise<ConnectionCheckResponse>;
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<string, { type?: string; title?: string; description?: string }>;
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<PluginAdminForm>(
() => schema.admin_form ?? { fields },
[schema.admin_form, fields],
);
const descriptor = useMemo<PluginAdminForm>(() => {
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<PluginConfigValue>(() =>
Object.fromEntries(fields.map((field) => [field.key, valueForField(field, value)])),
);
const [testResult, setTestResult] = useState<ConnectionCheckResponse | null>(null);
const [clearSecrets, setClearSecrets] = useState<Set<string>>(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 (
<div className="space-y-3 rounded-md border p-3">
<fieldset disabled={isSaving || isTesting} className="space-y-3 rounded-md border p-3">
<div className="space-y-1">
<Label>{schema.title || schema.key}</Label>
{schema.description ? (
@@ -199,6 +239,41 @@ export function PluginConfigForm({
idPrefix={schema.key}
/>
{configuredSecrets.length > 0 ? (
<div className="space-y-2 rounded-md border border-dashed p-2.5">
{configuredSecrets.map((key) => {
const field = fields.find((candidate) => candidate.key === key);
const clearing = clearSecrets.has(key);
const required = field?.required === true;
return (
<div key={key} className="flex items-center justify-between gap-3 text-xs">
<span className={clearing ? "text-destructive" : "text-muted-foreground"}>
{field?.label || humanizeKey(key)}: {clearing ? "will be cleared" : "saved"}
{required ? " (required)" : ""}
</span>
{!required ? (
<Button
type="button"
size="xs"
variant="ghost"
onClick={() =>
setClearSecrets((current) => {
const updated = new Set(current);
if (updated.has(key)) updated.delete(key);
else updated.add(key);
return updated;
})
}
>
{clearing ? "Keep saved secret" : "Clear saved secret"}
</Button>
) : null}
</div>
);
})}
</div>
) : null}
<div className="flex flex-wrap items-center gap-3">
{onTest ? (
<ConnectionCheckAction
@@ -212,11 +287,13 @@ export function PluginConfigForm({
size="sm"
variant="outline"
disabled={isSaving || isTesting}
onClick={() => onSave(schema.key, buildSchemaValues(descriptor, values))}
onClick={() =>
onSave(schema.key, buildSchemaValues(descriptor, values), Array.from(clearSecrets))
}
>
{schema.admin_form?.submit_label || "Save config"}
</Button>
</div>
</div>
</fieldset>
);
}
+1 -1
View File
@@ -41,7 +41,7 @@ export function RawCssEditor({ value, onChange }: RawCssEditorProps) {
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-[11px]">
Changes apply immediately. Saved automatically.
Preview updates as you type. Saved automatically after you pause.
</p>
<span
className={cn(
+5
View File
@@ -19,6 +19,7 @@ interface BrandingApiResponse {
mark_url?: string;
favicon_url?: string;
login_bg_url?: string;
storage_available?: boolean;
}
export interface BrandingContextValue {
@@ -33,6 +34,8 @@ export interface BrandingContextValue {
markUrl: string | null;
faviconUrl: string | null;
loginBgUrl: string | null;
/** Whether the running server has an active object-store client for assets. */
storageAvailable: boolean;
}
const DEFAULT_BRANDING: BrandingContextValue = {
@@ -44,6 +47,7 @@ const DEFAULT_BRANDING: BrandingContextValue = {
markUrl: null,
faviconUrl: null,
loginBgUrl: null,
storageAvailable: false,
};
// A non-null default means useBranding() is safe to call anywhere (e.g. in
@@ -60,6 +64,7 @@ function mapResponse(data: BrandingApiResponse | undefined): BrandingContextValu
markUrl: data?.mark_url || null,
faviconUrl: data?.favicon_url || null,
loginBgUrl: data?.login_bg_url || null,
storageAvailable: data?.storage_available ?? false,
};
}
+15 -9
View File
@@ -30,10 +30,12 @@ const ADMIN_STALE_TIME = 30_000;
export const CHECK_PLUGIN_UPDATES_TASK_KEY = "check_plugin_updates";
function invalidatePluginQueries(queryClient: ReturnType<typeof useQueryClient>) {
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) => {
+5 -2
View File
@@ -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");
+56 -1
View File
@@ -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<ServerSettings>("/admin/settings").then((d) => d ?? {}),
queryFn: () => api<ServerSettings>("/admin/settings/effective").then((d) => d ?? {}),
staleTime: 30_000,
});
}
export function useAdminServerStatus() {
return useQuery({
queryKey: adminKeys.serverStatus(),
queryFn: () => api<AdminServerStatus>("/admin/server/status"),
staleTime: 15_000,
});
}
export function useUpdateServerSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: Record<string, string>) =>
api<AdminSettingsUpdateResponse>("/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) => {
+5 -3
View File
@@ -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<SubtitleProviderTestResponse>(`/admin/subtitle-providers/${provider}/test`, {
method: "POST",
body: JSON.stringify(config),
}),
});
}
+1
View File
@@ -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,
+9 -1
View File
@@ -14,8 +14,16 @@ export function useAdminPublicCss() {
queryFn: async () => {
try {
const result = await api<AdminCssResponse>("/theme/admin-css");
let vars: Record<string, string> = {};
if (result.vars) {
try {
vars = JSON.parse(result.vars) as Record<string, string>;
} catch {
// Keep valid raw CSS active even if a legacy vars row is corrupt.
}
}
return {
vars: result.vars ? (JSON.parse(result.vars) as Record<string, string>) : {},
vars,
rawCss: result.raw_css ?? "",
};
} catch {
+77 -5
View File
@@ -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<string, string>; 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<void> | 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<string, string>) =>
Promise.resolve({
values,
restart_required: "database.max_connections" in values,
}),
);
const { result } = renderHook(() => useSettingsForm({ keys: KEYS }));
+77 -18
View File
@@ -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<Record<string, string>>({});
const [dirty, setDirty] = useState<Set<string>>(new Set());
const [restartRequired, setRestartRequired] = useState(false);
const editVersions = useRef(new Map<string, number>());
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<string, string> = {};
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,
+10 -2
View File
@@ -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",
+3 -32
View File
@@ -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),
},
];
+19
View File
@@ -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");
});
});
+16 -17
View File
@@ -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",
+217 -154
View File
@@ -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 (
<div className="surface-panel-subtle group relative overflow-hidden rounded-xl transition-all">
<div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:justify-between">
{/* Left: icon + info */}
<div className="flex items-start gap-4">
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-sm font-bold ${
installation.enabled ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground"
}`}
>
<Blocks className="h-5 w-5" />
</div>
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-[15px] leading-tight font-semibold">
{pluginDisplayName(installation.plugin_id, presentation)}
</h3>
<Badge variant="secondary" className="font-mono text-[11px]">
{installation.version}
</Badge>
<Badge variant="outline" className="text-[11px]">
{sourceLabel(installation.source_kind)}
</Badge>
{installation.available_version && (
<Badge variant="outline" className="border-amber-500/40 text-[11px] text-amber-500">
{installation.version} &rarr; {installation.available_version} available
</Badge>
)}
{installation.updates_paused ? (
<Badge variant="outline" className="text-muted-foreground text-[11px]">
Updates paused
</Badge>
) : null}
{installation.available_version && (
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-[11px]"
disabled={applyUpdate.isPending}
onClick={(e) => {
e.stopPropagation();
applyUpdate.mutate(installation.id);
}}
>
{applyUpdate.isPending ? (
<>
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
Updating...
</>
) : (
"Update"
)}
</Button>
)}
<span className="flex items-center gap-1.5">
<span
className={`inline-block h-2 w-2 rounded-full ${installation.enabled ? "bg-success" : "bg-muted-foreground"}`}
/>
<span className="text-muted-foreground text-[11px] font-medium">
{installation.enabled ? "Active" : "Inactive"}
</span>
</span>
<>
<div className="surface-panel-subtle group relative overflow-hidden rounded-xl transition-all">
<div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:justify-between">
{/* Left: icon + info */}
<div className="flex items-start gap-4">
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-sm font-bold ${
installation.enabled
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
<Blocks className="h-5 w-5" />
</div>
<p className="text-muted-foreground font-mono text-[11px]">{installation.plugin_id}</p>
<p className="text-muted-foreground max-w-3xl text-xs leading-relaxed">
{pluginSummary(presentation, capabilities)}
</p>
{capabilities.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{capabilities.map((cap) => (
<span
key={`${cap.type}:${cap.id}`}
className="bg-muted text-muted-foreground inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium"
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-[15px] leading-tight font-semibold">
{pluginDisplayName(installation.plugin_id, presentation)}
</h3>
<Badge variant="secondary" className="font-mono text-[11px]">
{installation.version}
</Badge>
<Badge variant="outline" className="text-[11px]">
{sourceLabel(installation.source_kind)}
</Badge>
{installation.available_version && (
<Badge
variant="outline"
className="border-amber-500/40 text-[11px] text-amber-500"
>
{cap.display_name || capabilityLabel(cap.type)}
</span>
))}
</div>
)}
<PluginResourceLinks presentation={presentation} repoURL={repoURL} />
</div>
</div>
{/* Right: actions */}
<div className="flex shrink-0 flex-wrap items-center gap-2 sm:ml-4">
{adminRoutes.length > 0 ? (
<>
{adminRoutes.map((route) => {
const href = pluginRouteHref(installation.id, route.path);
return (
{installation.version} &rarr; {installation.available_version} available
</Badge>
)}
{installation.updates_paused ? (
<Badge variant="outline" className="text-muted-foreground text-[11px]">
Updates paused
</Badge>
) : null}
{installation.available_version && (
<Button
key={route.id}
variant="outline"
size="sm"
onClick={() => void navigateToPluginRoute(href)}
className="h-6 px-2 text-[11px]"
disabled={applyUpdate.isPending}
onClick={(e) => {
e.stopPropagation();
applyUpdate.mutate(installation.id);
}}
>
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
{route.navigation_label || route.path}
{applyUpdate.isPending ? (
<>
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
Updating...
</>
) : (
"Update"
)}
</Button>
);
})}
<Button
variant="ghost"
size="icon-sm"
title="Plugin settings"
aria-label="Plugin settings"
onClick={() => onConfigure(installation)}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={() => onConfigure(installation)}>
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
Configure
</Button>
)}
<Select
value={installation.update_policy ?? "auto"}
onValueChange={(value) =>
updateInstallation.mutate({
id: installation.id,
body: { update_policy: value },
})
}
>
<SelectTrigger size="sm" className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="notify">Notify</SelectItem>
<SelectItem value="off">Off</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-2 rounded-lg border px-2.5 py-1.5">
<Switch
size="sm"
checked={installation.enabled}
onCheckedChange={(checked) =>
updateInstallation.mutate({ id: installation.id, body: { enabled: checked } })
}
/>
)}
<span className="flex items-center gap-1.5">
<span
className={`inline-block h-2 w-2 rounded-full ${installation.enabled ? "bg-success" : "bg-muted-foreground"}`}
/>
<span className="text-muted-foreground text-[11px] font-medium">
{installation.enabled ? "Active" : "Inactive"}
</span>
</span>
</div>
<p className="text-muted-foreground font-mono text-[11px]">
{installation.plugin_id}
</p>
<p className="text-muted-foreground max-w-3xl text-xs leading-relaxed">
{pluginSummary(presentation, capabilities)}
</p>
{capabilities.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{capabilities.map((cap) => (
<span
key={`${cap.type}:${cap.id}`}
className="bg-muted text-muted-foreground inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium"
>
{cap.display_name || capabilityLabel(cap.type)}
</span>
))}
</div>
)}
<PluginResourceLinks presentation={presentation} repoURL={repoURL} />
</div>
</div>
{/* Right: actions */}
<div className="flex shrink-0 flex-wrap items-center gap-2 sm:ml-4">
{adminRoutes.length > 0 ? (
<>
{adminRoutes.map((route) => {
const href = pluginRouteHref(installation.id, route.path);
return (
<Button
key={route.id}
variant="outline"
size="sm"
onClick={() => void navigateToPluginRoute(href)}
>
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
{route.navigation_label || route.path}
</Button>
);
})}
<Button
variant="ghost"
size="icon-sm"
title="Plugin settings"
aria-label="Plugin settings"
onClick={() => onConfigure(installation)}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={() => onConfigure(installation)}>
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
Configure
</Button>
)}
<Select
value={installation.update_policy ?? "auto"}
onValueChange={(value) =>
updateInstallation.mutate({
id: installation.id,
body: { update_policy: value },
})
}
>
<SelectTrigger size="sm" className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="notify">Notify</SelectItem>
<SelectItem value="off">Off</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-2 rounded-lg border px-2.5 py-1.5">
<Switch
size="sm"
checked={installation.enabled}
onCheckedChange={(checked) =>
updateInstallation.mutate({ id: installation.id, body: { enabled: checked } })
}
/>
</div>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => setConfirmDelete(true)}
aria-label={`Uninstall ${pluginDisplayName(installation.plugin_id, presentation)}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => deleteInstallation.mutate(installation.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Uninstall {pluginDisplayName(installation.plugin_id, presentation)}?
</AlertDialogTitle>
<AlertDialogDescription>
Silo will stop the plugin, then remove its installation, configuration, and installed
files. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteInstallation.mutate(installation.id)}
disabled={deleteInstallation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteInstallation.isPending ? "Uninstalling..." : "Uninstall plugin"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -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({
</AccordionTrigger>
<AccordionContent>
<div className="space-y-3">
<p className="text-muted-foreground text-xs">
Auth-provider bindings are registered at server startup. Saved changes require a
restart.
</p>
{authCapabilities.map((capability, index) => {
const binding = authBindings.find((e) => e.capability_id === capability.id);
return (
@@ -545,6 +602,7 @@ function ConfigureDialog({
</div>
<Switch
checked={binding?.enabled ?? false}
disabled={saveAuthBinding.isPending}
onCheckedChange={(checked) =>
saveAuthBinding.mutate({
id: installation.id,
@@ -577,6 +635,10 @@ function ConfigureDialog({
</AccordionTrigger>
<AccordionContent>
<div className="space-y-3">
<p className="text-muted-foreground text-xs">
Enable or disable each declared task binding. Task registration is rebuilt at
server startup, so saved changes require a restart.
</p>
{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}
</p>
<p className="text-muted-foreground font-mono text-xs">{capability.id}</p>
<p className="text-muted-foreground mt-1 text-xs">
Trigger: {JSON.stringify(binding?.trigger ?? { type: "startup" })}
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={() =>
<Switch
checked={binding?.enabled ?? true}
disabled={saveTaskBinding.isPending}
onCheckedChange={(checked) =>
saveTaskBinding.mutate({
id: installation.id,
capabilityId: capability.id,
body: {
enabled: binding?.enabled ?? true,
enabled: checked,
trigger: binding?.trigger ?? { type: "startup" },
},
})
}
>
Save binding
</Button>
/>
</div>
);
})}
+16 -19
View File
@@ -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<HTMLInputElement>(
'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 () => {
+69 -23
View File
@@ -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 && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setConfirmClear(true)}
disabled={isPending}
>
Clear credential
</Button>
)}
{confirmClear && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground text-xs">
Remove this credential from the server?
</span>
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => {
onLocalChange(key, "");
onCommit(key, "");
setConfirmClear(false);
}}
disabled={isPending}
>
Confirm clear
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setConfirmClear(false)}
disabled={isPending}
>
Cancel
</Button>
</div>
)}
{hint && <p className="text-muted-foreground text-xs">{hint}</p>}
</div>
);
@@ -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}
/>
</div>
) : null}
@@ -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<string, string> = {
"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 (
<div className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4">
<fieldset
disabled={updateSettings.isPending}
className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4"
>
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">Endpoint</h3>
@@ -243,20 +258,30 @@ function AIConnectionCard() {
hint="One shared cap across subtitle translation, transcription, and description translation."
/>
<div className="pt-2">
<Button type="button" onClick={save} disabled={updateSetting.isPending}>
{updateSetting.isPending ? "Saving..." : "Save Endpoint Settings"}
<Button type="button" onClick={() => void save()} disabled={updateSettings.isPending}>
{updateSettings.isPending ? "Saving..." : "Save Endpoint Settings"}
</Button>
<p className="text-muted-foreground mt-2 text-xs">
Changes take effect after a server restart.
Endpoint, model, and credential changes apply live. Changing the concurrency cap requires
a restart.
</p>
{restartRequired && (
<div className="border-warning/30 bg-warning/10 text-warning mt-3 flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-xs">
<span className="flex items-center gap-2">
<AlertTriangle className="h-3.5 w-3.5" />
Restart required to resize the AI job pool.
</span>
<RestartServerButton />
</div>
)}
</div>
</div>
</fieldset>
);
}
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<string, string> = {
"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 (
<div className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4">
<fieldset
disabled={updateSettings.isPending}
className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4"
>
<div className="mb-2">
<h3 className="text-sm font-semibold">Features</h3>
<p className="text-muted-foreground text-xs">
@@ -413,14 +442,12 @@ function AIFeaturesCard() {
hint="Rolling window the transcription limit counts against."
/>
<div className="pt-2">
<Button type="button" onClick={save} disabled={updateSetting.isPending}>
{updateSetting.isPending ? "Saving..." : "Save Feature Settings"}
<Button type="button" onClick={() => void save()} disabled={updateSettings.isPending}>
{updateSettings.isPending ? "Saving..." : "Save Feature Settings"}
</Button>
<p className="text-muted-foreground mt-2 text-xs">
Changes take effect after a server restart.
</p>
<p className="text-muted-foreground mt-2 text-xs">Feature changes apply live.</p>
</div>
</div>
</fieldset>
);
}
@@ -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<typeof import("@/hooks/queries/admin/settings")>()),
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 &amp; 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");
@@ -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() {
<div className="min-w-0 space-y-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Settings</h1>
<p className="page-subtitle text-sm sm:text-base">
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.
</p>
</div>
<SettingsSearchInput
@@ -124,6 +129,19 @@ export default function AdminSettingsLayout() {
/>
</div>
{serverStatus?.restart_required && (
<div
role="status"
className="surface-panel-subtle flex flex-col gap-3 rounded-xl p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="text-foreground/80 flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4" />
<span>Server restart required for saved settings to take effect.</span>
</div>
<RestartServerButton />
</div>
)}
<div className="surface-panel flex min-h-[500px] flex-col overflow-hidden rounded-[1.8rem] border-0 lg:flex-row">
{/* Mobile: horizontal scrolling pill bar */}
<nav
@@ -49,6 +49,7 @@ export default function BrandingSettings() {
// s3.public_bucket is not managed here, but getValue falls back to the full
// settings response so we can still gate the asset uploads on it.
const s3Configured = Boolean(form.getValue("s3.public_bucket"));
const assetStorageAvailable = branding.storageAvailable;
// Accent recolors the primary action color, focus ring, and sidebar accent
// (ACCENT_TOKENS). It merges into any overrides set via the Theming tab so
@@ -122,13 +123,22 @@ export default function BrandingSettings() {
</p>
</div>
{!s3Configured && (
{!assetStorageAvailable && (
<div className="flex items-start gap-3 rounded-xl border border-amber-500/20 bg-amber-500/5 p-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<p className="text-muted-foreground text-[13px] leading-relaxed">
Image uploads require S3 object storage. Configure a public bucket in{" "}
<span className="text-foreground font-medium">Storage</span> 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{" "}
<span className="text-foreground font-medium">Storage</span> settings, then
restart the server.
</>
)}
</p>
</div>
)}
@@ -140,7 +150,7 @@ export default function BrandingSettings() {
kind="wordmark"
currentUrl={branding.wordmarkUrl}
accept={IMAGE_ACCEPT}
enabled={s3Configured}
enabled={assetStorageAvailable}
preview="wide"
/>
<BrandingAssetField
@@ -149,7 +159,7 @@ export default function BrandingSettings() {
kind="mark"
currentUrl={branding.markUrl}
accept={IMAGE_ACCEPT}
enabled={s3Configured}
enabled={assetStorageAvailable}
preview="square"
/>
<BrandingAssetField
@@ -158,7 +168,7 @@ export default function BrandingSettings() {
kind="favicon"
currentUrl={branding.faviconUrl}
accept={FAVICON_ACCEPT}
enabled={s3Configured}
enabled={assetStorageAvailable}
preview="square"
/>
<BrandingAssetField
@@ -167,7 +177,7 @@ export default function BrandingSettings() {
kind="login_bg"
currentUrl={branding.loginBgUrl}
accept={IMAGE_ACCEPT}
enabled={s3Configured}
enabled={assetStorageAvailable}
preview="wide"
/>
</div>
@@ -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,
@@ -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() {
<FieldGroup label="User Database">
<SettingField
label="User DB Backend"
hint="postgres or sqlite"
type="select"
options={[
{ value: "postgres", label: "PostgreSQL" },
{ value: "sqlite", label: "SQLite" },
]}
value={form.getValue("userdb.backend")}
onChange={(v) => form.setValue("userdb.backend", v)}
/>
<SettingField
label="Pool Max Open"
type="number"
value={form.getValue("userdb.pool_max_open")}
onChange={(v) => form.setValue("userdb.pool_max_open", v)}
/>
<SettingField
label="Idle Timeout"
type="duration"
hint="e.g. 12h"
value={form.getValue("userdb.idle_timeout")}
onChange={(v) => form.setValue("userdb.idle_timeout", v)}
/>
<SettingField
label="Litestream Sync Interval"
type="duration"
hint="e.g. 1s"
value={form.getValue("userdb.litestream_sync")}
onChange={(v) => form.setValue("userdb.litestream_sync", v)}
/>
<SettingField
label="Stale Grace Seconds"
type="number"
value={form.getValue("userdb.stale_grace_seconds")}
onChange={(v) => form.setValue("userdb.stale_grace_seconds", v)}
/>
{form.getValue("userdb.backend") === "sqlite" && (
<>
<SettingField
label="Pool Max Open"
type="number"
value={form.getValue("userdb.pool_max_open")}
onChange={(v) => form.setValue("userdb.pool_max_open", v)}
/>
<SettingField
label="Idle Timeout"
type="duration"
hint="How long an inactive per-user SQLite connection remains open, e.g. 12h"
value={form.getValue("userdb.idle_timeout")}
onChange={(v) => form.setValue("userdb.idle_timeout", v)}
/>
</>
)}
</FieldGroup>
</div>
@@ -88,7 +88,7 @@ export default function DownloadSettings() {
/>
<SettingField
label="Artifact Directory"
hint="Where prepared (remux/transcode) download files are written. Empty = a 'downloads' subdirectory under the transcode directory."
hint="Where prepared (remux/transcode) download files are written. Empty = a 'silo-download-artifacts' directory beside the transcode directory."
value={form.getValue("download.artifact_dir")}
onChange={(v) => form.setValue("download.artifact_dir", v)}
/>
@@ -96,8 +96,8 @@ export default function EmailSettings() {
<div className="mb-6 space-y-2">
<h2 className="text-xl font-semibold tracking-tight">Email</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
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.
</p>
</div>
@@ -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() {
]}
/>
<SettingField
label="Quiet Subsystems"
hint="Comma-separated subsystem prefixes to silence"
label="Quiet Log Prefixes"
hint="Comma-separated message prefixes to silence, such as metadata or scanner. A trailing colon is optional."
value={form.getValue("server.log_quiet")}
onChange={(v) => form.setValue("server.log_quiet", v)}
/>
@@ -84,11 +85,15 @@ export default function GeneralSettings() {
<SettingField
label="Trusted Proxies"
hint={
"Comma-separated CIDRs of reverse proxies whose X-Forwarded-For is trusted, " +
"e.g. 172.16.0.0/12, 203.0.113.7/32. Applies without a restart."
(trustedProxiesManaged
? "Managed by SILO_TRUSTED_PROXIES. Remove that environment variable to edit here. "
: "") +
"Comma-separated CIDRs of reverse proxies whose X-Forwarded-For is trusted, e.g. " +
"172.16.0.0/12, 203.0.113.7/32. Applies without a restart."
}
value={form.getValue("clientip.trusted_proxies")}
onChange={(v) => form.setValue("clientip.trusted_proxies", v)}
disabled={trustedProxiesManaged}
/>
<div className="border-border/60 bg-muted/30 my-3 rounded-lg border px-3 py-2">
<p className="text-sm font-medium">Choosing trusted proxy ranges</p>
@@ -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<ConnectionCheckResponse | null>(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 (
<div className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4">
<fieldset
disabled={updateSettings.isPending || checkConnection.isPending}
className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">MDBList</h3>
@@ -47,10 +99,42 @@ function MDBListCredentialCard() {
sensitiveConfigured={configured}
hint="Leave blank to keep the current value."
/>
<Button type="button" onClick={save} disabled={updateSetting.isPending}>
{updateSetting.isPending ? "Saving..." : "Save MDBList API Key"}
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" onClick={() => void save()} disabled={updateSettings.isPending}>
{updateSettings.isPending ? "Saving..." : "Save MDBList API Key"}
</Button>
{configured && !confirmClear && (
<Button type="button" variant="outline" onClick={() => setConfirmClear(true)}>
Clear API key
</Button>
)}
{confirmClear && (
<>
<span className="text-muted-foreground text-xs">Disable MDBList discovery?</span>
<Button
type="button"
variant="destructive"
onClick={() => void clearKey()}
disabled={updateSettings.isPending}
>
Confirm clear
</Button>
<Button type="button" variant="ghost" onClick={() => setConfirmClear(false)}>
Cancel
</Button>
</>
)}
</div>
<ConnectionCheckAction
onClick={() => void testKey()}
result={connectionResult}
isPending={checkConnection.isPending}
disabled={updateSettings.isPending || (!configured && apiKey.trim() === "")}
/>
<p className="text-muted-foreground text-xs">
Test Connection uses the key entered above, or the saved key when the field is blank.
</p>
</fieldset>
);
}
@@ -133,7 +133,10 @@ function ProviderSettingsForm({ provider }: { provider: MarkerProviderConfig })
const validation = validateProvider.data;
return (
<div className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4">
<fieldset
disabled={updateProvider.isPending || validateProvider.isPending}
className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4"
>
<div className="mb-3 flex flex-col gap-1">
<h3 className="text-sm font-semibold">{displayName}</h3>
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
@@ -266,7 +269,7 @@ function ProviderSettingsForm({ provider }: { provider: MarkerProviderConfig })
{updateProvider.isPending ? "Saving..." : "Save Provider Settings"}
</Button>
</div>
</div>
</fieldset>
);
}
@@ -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<LogRetentionBucketPolicy>, fallbackID
export default function LogRetentionSettings() {
const { data: settings, isLoading } = useAdminServerSettings();
const updateSetting = useUpdateServerSetting();
const updateSettings = useUpdateServerSettings();
const [localValues, setLocalValues] = useState<Record<string, string>>({});
const [bucketRows, setBucketRows] = useState<BucketRow[]>([]);
@@ -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() {
</p>
</div>
<div className="flex-1 space-y-6">
<fieldset disabled={updateSettings.isPending} className="flex-1 space-y-6">
<FieldGroup label="Global Limits">
<SettingField
label="Retention Days"
@@ -243,7 +244,7 @@ export default function LogRetentionSettings() {
<SettingField
label="Decision Log Verbosity"
type="select"
hint="Digest stores hashes only. Verbose stores sampled input and result payloads."
hint="Digest omits sampled input and result payloads. Verbose can store those samples in addition to decision metadata."
value={getValue(POLICY_DECISION_LOG_VERBOSITY_KEY) || "digest"}
onChange={(value) => setValue(POLICY_DECISION_LOG_VERBOSITY_KEY, value)}
options={[
@@ -395,17 +396,17 @@ export default function LogRetentionSettings() {
<div className="text-muted-foreground text-xs leading-5">
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.
</div>
</div>
</FieldGroup>
</div>
</fieldset>
<SaveBar
dirtyCount={dirtyCount}
onSave={save}
onDiscard={discard}
isSaving={updateSetting.isPending}
isSaving={updateSettings.isPending}
restartRequired={restartRequired}
/>
</div>
@@ -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();
@@ -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 (
<div className="py-2">
<Button type="button" variant="ghost" size="sm" onClick={() => setOpen(true)}>
Clear Discord credentials
</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear all Discord credentials?</AlertDialogTitle>
<AlertDialogDescription>
This stages removal of the client ID, client secret, and bot token. Discord linking
and delivery stop after you save the settings form.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
onClear();
setOpen(false);
toast.info("Discord credential removal staged. Save changes to apply it.");
}}
>
Stage credential removal
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
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<AppleRelayRegisterResult | null>(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<void>("/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 (
<div className="space-y-3 py-3">
<SettingField
@@ -517,6 +593,16 @@ function RegisterRelayRow({
)}
{actionLabel}
</Button>
{configured && (
<Button
variant="ghost"
size="sm"
disabled={pending}
onClick={() => setConfirmClear(true)}
>
Clear credential
</Button>
)}
</div>
{reregistrationRequired && (
<div className="text-xs text-amber-500">
@@ -540,26 +626,48 @@ function RegisterRelayRow({
{result.relay_request_id ? ` — relay ${result.relay_request_id}` : ""}
</div>
)}
<AlertDialog open={confirmClear} onOpenChange={setConfirmClear}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear the push relay credential?</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={pending}
onClick={() => void clearRelay()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{pending ? "Clearing..." : "Clear credential"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function ApplePushPrivacyDisclosure() {
function MobilePushPrivacyDisclosure() {
return (
<div className="space-y-2 py-3">
<div className="text-sm font-medium">Privacy disclosure</div>
<div className="text-muted-foreground space-y-2 text-xs leading-relaxed">
<p>
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.
</p>
<p>
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.
</p>
<p>
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() {
<ChannelCard
icon={RadioTower}
title="Silo Push Relay"
description="Mobile push delivery through Silo's relay. Apple devices use APNs today; Android support will use the same relay when available."
enabled={applePushOn}
onEnabledChange={setToggle("notifications.apple_push_delivery_enabled")}
description="Content-free mobile wakeups through Silo's relay, delivered by APNs or FCM."
enabled={mobilePushOn}
onEnabledChange={(enabled) => {
form.setValue("notifications.apple_push_delivery_enabled", String(enabled));
form.setValue("notifications.android_push_delivery_enabled", String(enabled));
}}
chips={
pushRelayReregistrationRequired ? (
<Chip tone="warning">Re-registration required</Chip>
) : pushRelayAPIKeyReady ? (
<Chip tone="positive">Relay configured</Chip>
) : (
<Chip tone={applePushOn ? "warning" : "neutral"}>Relay registration required</Chip>
<Chip tone={mobilePushOn ? "warning" : "neutral"}>Relay registration required</Chip>
)
}
>
<div className="divide-border divide-y">
<ApplePushPrivacyDisclosure />
<MobilePushPrivacyDisclosure />
<SettingField
label="Apple Push (APNs)"
hint="Deliver generic wakeups to registered iPhone, iPad, Apple TV, and Mac devices."
type="toggle"
value={String(applePushOn)}
onChange={(value) =>
form.setValue("notifications.apple_push_delivery_enabled", value)
}
/>
<SettingField
label="Android Push (FCM)"
hint="Deliver generic wakeups to registered Android phone and TV devices."
type="toggle"
value={String(androidPushOn)}
onChange={(value) =>
form.setValue("notifications.android_push_delivery_enabled", value)
}
/>
<SettingField
label="Relay URL"
hint="Public relay endpoint used by this Silo server; stored when you register"
@@ -784,7 +915,12 @@ export default function NotificationsAdminSettings() {
expiresAt={pushRelayExpiresAt}
reregistrationRequired={pushRelayReregistrationRequired}
urlEdited={pushRelayURLEdited}
onRegistered={() => setPushRelayURLDraft(null)}
onRegistered={(submittedRelayURL) =>
setPushRelayURLDraft((currentDraft) => {
const currentURL = currentDraft ?? savedPushRelayURL;
return currentURL === submittedRelayURL ? null : currentDraft;
})
}
/>
</div>
</ChannelCard>
@@ -878,6 +1014,19 @@ export default function NotificationsAdminSettings() {
onChange={(v) => form.setValue("discord.bot_token", v)}
/>
<TestDiscordRow unsaved={discordCredentialsDirty} />
<ClearDiscordCredentialsRow
configured={
form.getValue("discord.client_id").trim() !== "" ||
form.sensitiveConfigured.some((key) =>
["discord.client_secret", "discord.bot_token"].includes(key),
)
}
onClear={() => {
form.setValue("discord.client_id", "");
form.setValue("discord.client_secret", "");
form.setValue("discord.bot_token", "");
}}
/>
</div>
<SubsectionLabel>Delivery</SubsectionLabel>
<div className="divide-border divide-y">
@@ -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<string, string> = {
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() {
<div className="mb-6 space-y-2">
<h2 className="text-xl font-semibold tracking-tight">Rate Limiting</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
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.
</p>
</div>
<div className="max-w-2xl space-y-4">
<fieldset disabled={updateConfig.isPending} className="max-w-2xl space-y-4">
{pendingRestart && (
<div className="surface-panel-subtle flex flex-col gap-3 rounded-xl p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-foreground/80 flex items-center gap-2 text-xs">
@@ -214,8 +226,8 @@ export default function RateLimitSettings() {
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
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.
</p>
</div>
</div>
@@ -240,7 +252,7 @@ export default function RateLimitSettings() {
className="w-full sm:w-40"
/>
<p className="text-muted-foreground text-xs">
Maximum requests per second across all clients combined.
Maximum requests per second across every route protected by the rate limiter.
</p>
</div>
</div>
@@ -248,7 +260,8 @@ export default function RateLimitSettings() {
<div className="surface-panel rounded-2xl border-0 px-5 py-4">
<div className="mb-1 text-sm font-semibold">Per-IP Limits</div>
<p className="text-muted-foreground mb-3 text-xs">
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.
</p>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-1">
@@ -365,7 +378,8 @@ export default function RateLimitSettings() {
<div className="surface-panel rounded-2xl border-0 px-5 py-4">
<div className="mb-1 text-sm font-semibold">Auth Endpoint Limits</div>
<p className="text-muted-foreground mb-3 text-xs">
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.
</p>
<div className="space-y-4">
{Object.keys(AUTH_ENDPOINT_LABELS).map((endpoint) => {
@@ -418,7 +432,7 @@ export default function RateLimitSettings() {
{updateConfig.isPending ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</fieldset>
</div>
);
}
@@ -150,7 +150,7 @@ function ChannelFormDialog({
JSON.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="server-channel-name">Name</Label>
<Input
@@ -190,7 +190,7 @@ function ChannelFormDialog({
))}
</div>
))}
</div>
</fieldset>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
@@ -7,6 +7,16 @@ import {
import type { SubtitleProviderConfig } from "@/api/types";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
@@ -47,11 +57,22 @@ function defaultSubtitleFormState(config: SubtitleProviderConfig): SubtitleProvi
};
}
function subtitleProviderDraft(form: SubtitleProviderFormState, withAccount: boolean) {
const user = form.username;
const pass = form.password;
const key = form.api_key;
return {
enabled: form.enabled,
...(withAccount ? { username: user, password: pass } : { api_key: key }),
};
}
function SubtitleProviderCard({ config }: { config: SubtitleProviderConfig }) {
const [form, setForm] = useState<SubtitleProviderFormState>(() =>
defaultSubtitleFormState(config),
);
const [testResult, setTestResult] = useState<SubtitleTestResult | null>(null);
const [confirmClear, setConfirmClear] = useState(false);
const updateProvider = useUpdateSubtitleProvider();
const testProvider = useTestSubtitleProvider();
@@ -66,6 +87,8 @@ function SubtitleProviderCard({ config }: { config: SubtitleProviderConfig }) {
const providerName = config.provider_name;
const displayName = SUBTITLE_PROVIDER_NAMES[providerName] ?? providerName;
const isOpenSubtitles = providerName === "opensubtitles";
const credentialsConfigured =
(isOpenSubtitles && config.has_credentials) || (!isOpenSubtitles && config.has_api_key);
function handleSave() {
updateProvider.mutate({
@@ -81,21 +104,30 @@ function SubtitleProviderCard({ config }: { config: SubtitleProviderConfig }) {
function handleTest() {
setTestResult(null);
testProvider.mutate(providerName, {
onSuccess: (result) => {
setTestResult({ success: result.success, error: result.error });
testProvider.mutate(
{
provider: providerName,
config: subtitleProviderDraft(form, isOpenSubtitles),
},
onError: (err) => {
setTestResult({
success: false,
error: err instanceof Error ? err.message : "Test failed",
});
{
onSuccess: (result) => {
setTestResult({ success: result.success, error: result.error });
},
onError: (err) => {
setTestResult({
success: false,
error: err instanceof Error ? err.message : "Test failed",
});
},
},
});
);
}
return (
<div className="border-border bg-surface space-y-4 rounded-lg border px-5 py-4">
<fieldset
disabled={updateProvider.isPending || testProvider.isPending}
className="border-border bg-surface space-y-4 rounded-lg border px-5 py-4"
>
{/* Header row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -182,6 +214,15 @@ function SubtitleProviderCard({ config }: { config: SubtitleProviderConfig }) {
<Button onClick={handleSave} disabled={updateProvider.isPending}>
{updateProvider.isPending ? "Saving..." : "Save"}
</Button>
{credentialsConfigured && (
<Button
variant="ghost"
onClick={() => setConfirmClear(true)}
disabled={updateProvider.isPending}
>
Clear credentials
</Button>
)}
{testResult !== null && (
<span className={`text-sm ${testResult.success ? "text-green-500" : "text-red-500"}`}>
{testResult.success
@@ -190,7 +231,42 @@ function SubtitleProviderCard({ config }: { config: SubtitleProviderConfig }) {
</span>
)}
</div>
</div>
<p className="text-muted-foreground text-xs">
Test Connection uses the values currently entered above. Saving applies provider changes
live to new searches.
</p>
<AlertDialog open={confirmClear} onOpenChange={setConfirmClear}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear {displayName} credentials?</AlertDialogTitle>
<AlertDialogDescription>
The provider will be disabled and removed from live subtitle searches immediately.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={updateProvider.isPending}
onClick={() =>
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"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</fieldset>
);
}
@@ -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 }) => (
<>
<button type="button" onClick={() => onSetVar("primary", "#112233")}>
Set primary
</button>
<button type="button" onClick={() => onSetVar("primary", "#445566")}>
Set latest primary
</button>
</>
),
}));
vi.mock("@/components/theme/RawCssEditor", () => ({
RawCssEditor: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
<textarea
aria-label="Custom CSS editor"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
vi.mock("@/components/theme/ThemePreviewCard", () => ({
ThemePreviewCard: () => null,
}));
import ThemeSettings from "./ThemeSettings";
describe("ThemeSettings autosave", () => {
beforeEach(() => {
vi.useFakeTimers();
mocks.updateMutate.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("flushes the latest pending variables and CSS when navigating away", () => {
const { unmount } = render(<ThemeSettings />);
fireEvent.click(screen.getByRole("button", { name: "Set primary" }));
fireEvent.click(screen.getByRole("button", { name: "Set latest primary" }));
fireEvent.change(screen.getByRole("textbox", { name: "Custom CSS editor" }), {
target: { value: '@import "https://example.invalid/theme.css"; .card { color: red; }' },
});
expect(mocks.updateMutate).not.toHaveBeenCalled();
unmount();
expect(mocks.updateMutate).toHaveBeenCalledTimes(2);
expect(mocks.updateMutate).toHaveBeenCalledWith({
key: "ui.admin_theme_vars",
value: JSON.stringify({ primary: "#445566" }),
});
expect(mocks.updateMutate).toHaveBeenCalledWith({
key: "ui.admin_custom_css",
value: "/* [blocked @import] */ .card { color: red; }",
});
});
it("does not save completed debounces again during unmount", () => {
const { unmount } = render(<ThemeSettings />);
fireEvent.click(screen.getByRole("button", { name: "Set primary" }));
fireEvent.change(screen.getByRole("textbox", { name: "Custom CSS editor" }), {
target: { value: ".card { color: red; }" },
});
act(() => {
vi.advanceTimersByTime(1_000);
});
expect(mocks.updateMutate).toHaveBeenCalledTimes(2);
unmount();
expect(mocks.updateMutate).toHaveBeenCalledTimes(2);
});
});
+60 -15
View File
@@ -16,6 +16,10 @@ export default function ThemeSettings() {
const [vars, setVars] = useState<ThemeVarOverrides>({});
const [rawCss, setRawCss] = useState("");
const [catalogUrl, setCatalogUrl] = useState("");
const mutateSettingRef = useRef(updateSetting.mutate);
useEffect(() => {
mutateSettingRef.current = updateSetting.mutate;
}, [updateSetting.mutate]);
// Only seed local state once from the first server response
const seededRef = useRef(false);
@@ -34,12 +38,43 @@ export default function ThemeSettings() {
// Debounce timers
const varsTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const cssTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const pendingVarsRef = useRef<ThemeVarOverrides | null>(null);
const pendingCssRef = useRef<string | null>(null);
const persistVars = useCallback(
(newVars: ThemeVarOverrides) => {
updateSetting.mutate({ key: "ui.admin_theme_vars", value: JSON.stringify(newVars) });
const persistVars = useCallback((newVars: ThemeVarOverrides) => {
mutateSettingRef.current({
key: "ui.admin_theme_vars",
value: JSON.stringify(newVars),
});
}, []);
const flushPendingVars = useCallback(() => {
clearTimeout(varsTimerRef.current);
varsTimerRef.current = undefined;
const pending = pendingVarsRef.current;
if (pending === null) return;
pendingVarsRef.current = null;
persistVars(pending);
}, [persistVars]);
const flushPendingCss = useCallback(() => {
clearTimeout(cssTimerRef.current);
cssTimerRef.current = undefined;
const pending = pendingCssRef.current;
if (pending === null) return;
pendingCssRef.current = null;
mutateSettingRef.current({
key: "ui.admin_custom_css",
value: sanitizeCss(pending),
});
}, []);
useEffect(
() => () => {
flushPendingVars();
flushPendingCss();
},
[updateSetting],
[flushPendingCss, flushPendingVars],
);
const handleSetVar = useCallback(
@@ -47,15 +82,19 @@ export default function ThemeSettings() {
setVars((prev) => {
const next = { ...prev, [token]: value };
clearTimeout(varsTimerRef.current);
varsTimerRef.current = setTimeout(() => persistVars(next), 500);
pendingVarsRef.current = next;
varsTimerRef.current = setTimeout(flushPendingVars, 500);
return next;
});
},
[persistVars],
[flushPendingVars],
);
const handleResetVar = useCallback(
(token: ThemeToken) => {
clearTimeout(varsTimerRef.current);
varsTimerRef.current = undefined;
pendingVarsRef.current = null;
setVars((prev) => {
const next = { ...prev };
delete next[token];
@@ -70,23 +109,28 @@ export default function ThemeSettings() {
(css: string) => {
setRawCss(css);
clearTimeout(cssTimerRef.current);
cssTimerRef.current = setTimeout(() => {
updateSetting.mutate({ key: "ui.admin_custom_css", value: sanitizeCss(css) });
}, 1000);
pendingCssRef.current = css;
cssTimerRef.current = setTimeout(flushPendingCss, 1000);
},
[updateSetting],
[flushPendingCss],
);
const handleCatalogUrlBlur = useCallback(() => {
updateSetting.mutate({ key: "theme.catalog_url", value: catalogUrl });
}, [updateSetting, catalogUrl]);
mutateSettingRef.current({ key: "theme.catalog_url", value: catalogUrl });
}, [catalogUrl]);
const handleResetAll = useCallback(() => {
clearTimeout(varsTimerRef.current);
clearTimeout(cssTimerRef.current);
varsTimerRef.current = undefined;
cssTimerRef.current = undefined;
pendingVarsRef.current = null;
pendingCssRef.current = null;
setVars({});
setRawCss("");
persistVars({});
updateSetting.mutate({ key: "ui.admin_custom_css", value: "" });
}, [persistVars, updateSetting]);
mutateSettingRef.current({ key: "ui.admin_custom_css", value: "" });
}, [persistVars]);
const hasOverrides = Object.keys(vars).length > 0 || rawCss.length > 0;
@@ -137,7 +181,8 @@ export default function ThemeSettings() {
<div>
<h4 className="mb-2 text-sm font-medium">Theme Catalog URL</h4>
<p className="text-muted-foreground mb-2 text-[13px]">
URL of the community theme catalog JSON index. Users browse this in their settings.
HTTPS URL of the GitHub-hosted community theme catalog JSON index. Users browse this in
their settings.
</p>
<input
type="url"
@@ -1,8 +1,14 @@
import { useState } from "react";
import { useAdminSensitiveStatus, useUpdateServerSetting } from "@/hooks/queries/admin/settings";
import { AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import {
useAdminSensitiveStatus,
useUpdateServerSettings as useUpdateServerSetting,
} from "@/hooks/queries/admin/settings";
import { Button } from "@/components/ui/button";
import { CredentialStatus } from "./CredentialStatus";
import { RestartServerButton } from "./RestartServerButton";
import { SettingField } from "./SettingField";
interface WatchProviderCredentials {
@@ -20,31 +26,56 @@ function WatchProviderCredentialCard({ provider }: { provider: WatchProviderCred
const updateSetting = useUpdateServerSetting();
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [confirmClear, setConfirmClear] = useState(false);
const [restartRequired, setRestartRequired] = useState(false);
const configured = new Set(sensitive?.configured ?? []);
const clientIdKey = `watchsync.${provider.key}.client_id`;
const clientSecretKey = `watchsync.${provider.key}.client_secret`;
function save() {
const updates = [];
async function save() {
const updates: Record<string, string> = {};
if (clientId.trim() !== "") {
updates.push(updateSetting.mutateAsync({ key: clientIdKey, value: clientId }));
updates[clientIdKey] = clientId;
}
if (clientSecret.trim() !== "") {
updates.push(
updateSetting.mutateAsync({
key: clientSecretKey,
value: clientSecret,
}),
);
updates[clientSecretKey] = clientSecret;
}
void Promise.all(updates).then(() => {
if (Object.keys(updates).length === 0) {
toast.info(`No ${provider.displayName} credentials changed.`);
return;
}
try {
const result = await updateSetting.mutateAsync(updates);
setClientId("");
setClientSecret("");
});
setRestartRequired((current) => current || result.restart_required);
toast.success(`${provider.displayName} credentials saved`);
} catch {
// The mutation reports the API error.
}
}
async function clearCredentials() {
try {
const result = await updateSetting.mutateAsync({
[clientIdKey]: "",
[clientSecretKey]: "",
});
setClientId("");
setClientSecret("");
setConfirmClear(false);
setRestartRequired((current) => current || result.restart_required);
toast.success(`${provider.displayName} credentials cleared`);
} catch {
// The mutation reports the API error.
}
}
return (
<div className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4">
<fieldset
disabled={updateSetting.isPending}
className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">{provider.displayName}</h3>
@@ -72,10 +103,45 @@ function WatchProviderCredentialCard({ provider }: { provider: WatchProviderCred
sensitiveConfigured={configured.has(clientSecretKey)}
hint="Leave blank to keep the current value."
/>
<Button type="button" onClick={save} disabled={updateSetting.isPending}>
{updateSetting.isPending ? "Saving..." : `Save ${provider.displayName} Credentials`}
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" onClick={() => void save()} disabled={updateSetting.isPending}>
{updateSetting.isPending ? "Saving..." : `Save ${provider.displayName} Credentials`}
</Button>
{(configured.has(clientIdKey) || configured.has(clientSecretKey)) && !confirmClear && (
<Button type="button" variant="outline" onClick={() => setConfirmClear(true)}>
Clear credentials
</Button>
)}
{confirmClear && (
<>
<span className="text-muted-foreground text-xs">
Disconnect this server credential?
</span>
<Button
type="button"
variant="destructive"
onClick={() => void clearCredentials()}
disabled={updateSetting.isPending}
>
Confirm clear
</Button>
<Button type="button" variant="ghost" onClick={() => setConfirmClear(false)}>
Cancel
</Button>
</>
)}
</div>
{restartRequired && (
<div className="border-warning/30 bg-warning/10 text-warning mt-3 flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-xs">
<span className="flex items-center gap-2">
<AlertTriangle className="h-3.5 w-3.5" />
Restart required for {provider.displayName} collection browsing to use this credential
change.
</span>
<RestartServerButton />
</div>
)}
</fieldset>
);
}
@@ -63,18 +63,30 @@ function ProviderCard({ config }: { config: SubtitleProviderConfig }) {
function handleTest() {
setTestResult(null);
testProvider.mutate(providerName, {
onSuccess: (result) => setTestResult({ success: result.success, error: result.error }),
onError: (err) =>
setTestResult({
success: false,
error: err instanceof Error ? err.message : "Test failed",
}),
});
testProvider.mutate(
{
provider: providerName,
config: {
enabled,
...(isOpenSubtitles ? { username, password } : { api_key: apiKey }),
},
},
{
onSuccess: (result) => setTestResult({ success: result.success, error: result.error }),
onError: (err) =>
setTestResult({
success: false,
error: err instanceof Error ? err.message : "Test failed",
}),
},
);
}
return (
<div className="bg-foreground/[0.03] hover:bg-foreground/[0.05] border-foreground/[0.07] rounded-xl border transition-colors">
<fieldset
disabled={updateProvider.isPending || testProvider.isPending}
className="bg-foreground/[0.03] hover:bg-foreground/[0.05] border-foreground/[0.07] rounded-xl border transition-colors"
>
{/* Card header */}
<div className="flex items-center justify-between px-4 py-3.5">
<div className="min-w-0 flex-1">
@@ -166,7 +178,7 @@ function ProviderCard({ config }: { config: SubtitleProviderConfig }) {
</div>
</div>
)}
</div>
</fieldset>
);
}