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" playbackSegmentRetentionSettingKey = "playback.segment_retention_seconds" chapterThumbnailSoftwareToneMapKey = "playback.chapter_thumbnail_software_tone_map_enabled" ) // 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, playbackSegmentRetentionSettingKey: "600", "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", chapterThumbnailSoftwareToneMapKey: "false", "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", chapterThumbnailSoftwareToneMapKey, "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 playbackSegmentRetentionSettingKey: normalized, err := normalizeAdminInt(key, value, 0, 86400) if err != nil { return "", err } seconds, _ := strconv.Atoi(normalized) if seconds != 0 && seconds < 120 { return "", fmt.Errorf("%s must be 0 or between 120 and 86400", key) } return normalized, nil 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 }