Merge pull request #456 from Silo-Server/feat/admin-settings-contract
fix(admin): enforce settings contracts end to end
This commit is contained in:
+20
-3
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
¬ifications.System{Settings: notifications.NewSettings(settings)},
|
||||
settings,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleClearRelay(
|
||||
rec,
|
||||
httptest.NewRequest(http.MethodDelete, "/admin/notifications/push/relay", nil),
|
||||
)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d (%s), want 204", rec.Code, rec.Body.String())
|
||||
}
|
||||
if settings.setManyCalls != 1 {
|
||||
t.Fatalf("SetMany calls = %d, want 1", settings.setManyCalls)
|
||||
}
|
||||
for _, key := range []string{
|
||||
notifications.SettingPushRelayURL,
|
||||
notifications.SettingPushRelayDeploymentID,
|
||||
notifications.SettingPushRelayAPIKey,
|
||||
notifications.SettingPushRelayKeyPrefix,
|
||||
notifications.SettingPushRelayExpiresAt,
|
||||
} {
|
||||
if settings.values[key] != "" {
|
||||
t.Fatalf("%s = %q, want empty", key, settings.values[key])
|
||||
}
|
||||
}
|
||||
if settings.values[notifications.SettingPushRelayReregister] != "false" {
|
||||
t.Fatalf(
|
||||
"reregistration marker = %q, want false",
|
||||
settings.values[notifications.SettingPushRelayReregister],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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+":")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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} → {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} → {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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 & 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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user