Add a typed, cached registry over the system_settings table so that values which used to be hardcoded Go literals can be changed at runtime. Schema (database/schema/schema.sql): - Extend system_settings with setting_type, min_value, max_value, requires_restart, and category columns (all ADD COLUMN IF NOT EXISTS, nullable for backward compat with the original three rows). - Seed rows for every tunable: session duration, password rules, login lockout, auth/device rate limits, OPDS page size, tombstone TTL, conversion cache TTL, sync queue interval/batch, and worker pool size/cap. Seed values equal the previous hardcoded literals, so behavior is unchanged on upgrade. ON CONFLICT DO NOTHING preserves any admin-modified values. Queries (queries.sql): - Add UpsertSystemSetting (RETURNING *) so new keys without a seed row can still be written through the API. - Add GetSystemSettingFull + GetAllSystemSettingsFull returning the full typed row. - Refactor CleanupExpiredRefreshTokens to take the retention window as a parameter (make_interval(secs => $1)) instead of the INTERVAL '7 days' literal, so it can follow a configurable session duration. Registry (internal/database/settings_registry.go): - SettingsRegistry holds an in-memory cache of all known settings, populated by Load at startup and refreshed by Reload on writes. - Typed domain getters (SessionDuration, PasswordRules, DeviceRateLimits, TombstoneTTL, OpdsPageSize, ConversionCacheTTL, SyncQueueConfig, WorkerPoolConfig, LoginLockout, AuthRateLimit, ...) with compiled-in fallback defaults and min/max clamping, so a corrupt or missing row can never break the app. - SettingDefaults is the single source of truth for keys, types, bounds, and human descriptions; All() exposes metadata + current values for the admin UI/API. The registry lives in the database package (rather than its own internal/settings package) because a quirk in this custom go1.26.5 toolchain prevented the large handlers package from importing any newly-created package; every consumer already imports database. Tests: settings_registry_test.go covers default validity per type, int clamping at both bounds, garbage-value fallback, and unknown-key lookup.
343 lines
14 KiB
Go
343 lines
14 KiB
Go
package database
|
|
|
|
// SettingsRegistry provides a typed, cached view over the system_settings table.
|
|
// It is the single source of truth for tunable runtime values that used to be
|
|
// hardcoded as Go literals.
|
|
//
|
|
// Consumers call the domain-specific getters (SessionDuration, OpdsPageSize,
|
|
// etc.) which read from an in-memory cache. The cache is populated by Load at
|
|
// startup and refreshed by Reload whenever a setting is written. Getters always
|
|
// fall back to a compiled-in default if the DB value is missing or unparsable,
|
|
// so a corrupt or deleted row can never break the app.
|
|
//
|
|
// SettingsRegistry lives in the database package (rather than its own package)
|
|
// so that every consumer already imports database and does not need to take on
|
|
// a new package import.
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// SettingType enumerates the value types stored in system_settings.setting_type.
|
|
const (
|
|
SettingTypeInt = "int"
|
|
SettingTypeBool = "bool"
|
|
SettingTypeString = "string"
|
|
SettingTypeStringList = "string_list"
|
|
)
|
|
|
|
// SecondsPerDay / SecondsPerHour are conversion helpers used by defaults.
|
|
const (
|
|
SecondsPerMinute = 60
|
|
SecondsPerHour = 3600
|
|
SecondsPerDay = 86400
|
|
)
|
|
|
|
// SettingDefault holds the fallback value for a key. These mirror the literals that
|
|
// were previously hardcoded in the source so an empty/corrupt DB row preserves
|
|
// prior behavior exactly.
|
|
type SettingDefault struct {
|
|
Key string
|
|
Value string
|
|
Type string
|
|
Min string
|
|
Max string
|
|
RequiresRestart bool
|
|
Category string
|
|
Group string
|
|
Description string
|
|
}
|
|
|
|
// SettingDefaults is the source of truth for fallback values and metadata. New keys
|
|
// must be added here AND seeded in database/schema/schema.sql. Entries are ordered
|
|
// by (RequiresRestart, Group) so the admin UI renders coherent sub-sections.
|
|
var SettingDefaults = []SettingDefault{
|
|
{Key: "scan_poll_interval_seconds", Value: "60", Type: SettingTypeInt, Min: "1", Max: "3600", Category: "scanner", Group: "Scanning", Description: "How often to scan all libraries (seconds)"},
|
|
{Key: "auto_scan_enabled", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Whether auto-scanning is enabled system-wide"},
|
|
{Key: "default_timezone", Value: "UTC", Type: SettingTypeString, Category: "general", Group: "System Defaults", Description: "System default timezone"},
|
|
{Key: "session_duration_seconds", Value: "604800", Type: SettingTypeInt, Min: "300", Max: "31536000", Category: "security", Group: "Session", Description: "How long a login session stays valid"},
|
|
{Key: "password_min_length", Value: "8", Type: SettingTypeInt, Min: "1", Max: "128", Category: "security", Group: "Password Quality", Description: "Minimum password length"},
|
|
{Key: "password_require_upper", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one uppercase letter (A-Z)"},
|
|
{Key: "password_require_lower", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one lowercase letter (a-z)"},
|
|
{Key: "password_require_number", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one number (0-9)"},
|
|
{Key: "password_require_special", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one special character"},
|
|
{Key: "opds_default_page_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "500", Category: "api", Group: "OPDS Catalog", Description: "Default OPDS page size"},
|
|
{Key: "opds_max_page_size", Value: "200", Type: SettingTypeInt, Min: "1", Max: "1000", Category: "api", Group: "OPDS Catalog", Description: "Maximum OPDS page size"},
|
|
{Key: "device_rate_sync_per_min", Value: "60", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device sync requests per minute"},
|
|
{Key: "device_rate_progress_per_min", Value: "120", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device progress requests per minute"},
|
|
{Key: "device_rate_metadata_per_min", Value: "30", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device metadata requests per minute"},
|
|
{Key: "annotation_tombstone_ttl_days", Value: "30", Type: SettingTypeInt, Min: "1", Max: "3650", Category: "sync", Group: "Annotation Retention", Description: "How long deleted annotations are kept before purge"},
|
|
{Key: "conversion_cache_ttl_hours", Value: "24", Type: SettingTypeInt, Min: "1", Max: "720", Category: "performance", Group: "Conversion Cache", Description: "How long converted (kepub) files are cached"},
|
|
{Key: "auth_rate_limit_per_min", Value: "10", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "security", Group: "Auth Rate Limiting", Description: "Global auth API rate limit (requests per minute)"},
|
|
{Key: "login_max_attempts", Value: "5", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "security", Group: "Login Lockout", Description: "Failed login attempts before lockout"},
|
|
{Key: "login_lockout_minutes", Value: "15", Type: SettingTypeInt, Min: "1", Max: "10080", RequiresRestart: true, Category: "security", Group: "Login Lockout", Description: "Lockout duration after too many failed logins"},
|
|
{Key: "sync_queue_interval_seconds", Value: "5", Type: SettingTypeInt, Min: "1", Max: "3600", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "How often the sync queue flushes"},
|
|
{Key: "sync_queue_batch_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "Maximum items processed per sync queue flush"},
|
|
{Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
|
|
{Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
|
|
}
|
|
|
|
// defaultBy indexes SettingDefaults by key for O(1) lookup.
|
|
var defaultBy = func() map[string]SettingDefault {
|
|
m := make(map[string]SettingDefault, len(SettingDefaults))
|
|
for _, d := range SettingDefaults {
|
|
m[d.Key] = d
|
|
}
|
|
return m
|
|
}()
|
|
|
|
// Registry caches system_settings values in memory. The zero value is not
|
|
// usable; construct with New.
|
|
type SettingsRegistry struct {
|
|
q *Queries
|
|
|
|
mu sync.RWMutex
|
|
values map[string]string
|
|
loadedAt time.Time
|
|
}
|
|
|
|
// New returns a Registry backed by the given queries. The cache is empty
|
|
// until Load is called.
|
|
func NewSettingsRegistry(q *Queries) *SettingsRegistry {
|
|
return &SettingsRegistry{q: q, values: make(map[string]string)}
|
|
}
|
|
|
|
// Load populates the cache from the database. Missing rows fall back to the
|
|
// compiled defaults. Safe to call multiple times.
|
|
func (r *SettingsRegistry) Load(ctx context.Context) error {
|
|
rows, err := r.q.GetAllSystemSettings(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fresh := make(map[string]string, len(SettingDefaults))
|
|
for _, d := range SettingDefaults {
|
|
fresh[d.Key] = d.Value
|
|
}
|
|
for _, row := range rows {
|
|
if _, ok := fresh[row.SettingKey]; ok {
|
|
fresh[row.SettingKey] = row.SettingValue
|
|
}
|
|
}
|
|
r.mu.Lock()
|
|
r.values = fresh
|
|
r.loadedAt = time.Now()
|
|
r.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Reload refreshes the cache from the database. Should be called after any
|
|
// setting write. On error the cache is left untouched and the error is logged.
|
|
func (r *SettingsRegistry) Reload(ctx context.Context) {
|
|
if err := r.Load(ctx); err != nil {
|
|
log.Printf("settings: reload failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// raw returns the cached string value for a key (or the default), clamped to
|
|
// [min, max] for int-typed keys.
|
|
func (r *SettingsRegistry) raw(key string) string {
|
|
r.mu.RLock()
|
|
v, ok := r.values[key]
|
|
r.mu.RUnlock()
|
|
if !ok || v == "" {
|
|
v = defaultBy[key].Value
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (r *SettingsRegistry) getInt(key string) int {
|
|
d := defaultBy[key]
|
|
v := r.raw(key)
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
n, _ = strconv.Atoi(d.Value)
|
|
}
|
|
if d.Min != "" {
|
|
if mn, err := strconv.Atoi(d.Min); err == nil && n < mn {
|
|
n = mn
|
|
}
|
|
}
|
|
if d.Max != "" {
|
|
if mx, err := strconv.Atoi(d.Max); err == nil && n > mx {
|
|
n = mx
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (r *SettingsRegistry) getBool(key string) bool {
|
|
v := r.raw(key)
|
|
b, err := strconv.ParseBool(v)
|
|
if err != nil {
|
|
b, _ = strconv.ParseBool(defaultBy[key].Value)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// ---- Domain-specific getters (call sites use these) ----
|
|
|
|
// ScanPollInterval is how often the scanner polls, as a duration.
|
|
func (r *SettingsRegistry) ScanPollInterval() time.Duration {
|
|
return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
|
|
}
|
|
|
|
// AutoScanEnabled reports whether auto-scanning is on.
|
|
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
|
|
|
// DefaultTimezone returns the configured default timezone name.
|
|
func (r *SettingsRegistry) DefaultTimezone() string { return r.raw("default_timezone") }
|
|
|
|
// SessionDuration is how long a login session / refresh token stays valid.
|
|
func (r *SettingsRegistry) SessionDuration() time.Duration {
|
|
return time.Duration(r.getInt("session_duration_seconds")) * time.Second
|
|
}
|
|
|
|
// PasswordMinLength is the minimum password length.
|
|
func (r *SettingsRegistry) PasswordMinLength() int { return r.getInt("password_min_length") }
|
|
|
|
// PasswordRules bundles the active complexity requirements.
|
|
type PasswordRules struct {
|
|
MinLength int
|
|
Upper bool
|
|
Lower bool
|
|
Number bool
|
|
Special bool
|
|
}
|
|
|
|
// PasswordRules returns the active password complexity configuration.
|
|
func (r *SettingsRegistry) PasswordRules() PasswordRules {
|
|
return PasswordRules{
|
|
MinLength: r.PasswordMinLength(),
|
|
Upper: r.getBool("password_require_upper"),
|
|
Lower: r.getBool("password_require_lower"),
|
|
Number: r.getBool("password_require_number"),
|
|
Special: r.getBool("password_require_special"),
|
|
}
|
|
}
|
|
|
|
// AuthRateLimit is the global auth endpoint rate limit (requests/minute). Read
|
|
// once at startup.
|
|
func (r *SettingsRegistry) AuthRateLimit() int { return r.getInt("auth_rate_limit_per_min") }
|
|
|
|
// LoginLockout returns (max attempts, lockout duration). Read once at startup.
|
|
func (r *SettingsRegistry) LoginLockout() (int, time.Duration) {
|
|
return r.getInt("login_max_attempts"), time.Duration(r.getInt("login_lockout_minutes")) * time.Minute
|
|
}
|
|
|
|
// OpdsDefaultPageSize is the default OPDS items-per-page.
|
|
func (r *SettingsRegistry) OpdsDefaultPageSize() int { return r.getInt("opds_default_page_size") }
|
|
|
|
// OpdsMaxPageSize is the maximum items-per-page a client may request.
|
|
func (r *SettingsRegistry) OpdsMaxPageSize() int { return r.getInt("opds_max_page_size") }
|
|
|
|
// DeviceRateLimits bundles the per-route device rate limits (requests/minute).
|
|
type DeviceRateLimits struct {
|
|
Sync int
|
|
Progress int
|
|
Metadata int
|
|
}
|
|
|
|
// DeviceRateLimits returns the active device rate limits.
|
|
func (r *SettingsRegistry) DeviceRateLimits() DeviceRateLimits {
|
|
return DeviceRateLimits{
|
|
Sync: r.getInt("device_rate_sync_per_min"),
|
|
Progress: r.getInt("device_rate_progress_per_min"),
|
|
Metadata: r.getInt("device_rate_metadata_per_min"),
|
|
}
|
|
}
|
|
|
|
// TombstoneTTL is how long deleted annotations are retained before purge.
|
|
func (r *SettingsRegistry) TombstoneTTL() time.Duration {
|
|
return time.Duration(r.getInt("annotation_tombstone_ttl_days")) * 24 * time.Hour
|
|
}
|
|
|
|
// ConversionCacheTTL is how long converted (kepub) files are served from cache.
|
|
func (r *SettingsRegistry) ConversionCacheTTL() time.Duration {
|
|
return time.Duration(r.getInt("conversion_cache_ttl_hours")) * time.Hour
|
|
}
|
|
|
|
// SyncQueueConfig bundles the sync queue interval and batch size. Read at
|
|
// startup; changes require a restart.
|
|
type SyncQueueConfig struct {
|
|
Interval time.Duration
|
|
BatchSize int
|
|
}
|
|
|
|
// SyncQueueConfig returns the active sync queue configuration.
|
|
func (r *SettingsRegistry) SyncQueueConfig() SyncQueueConfig {
|
|
return SyncQueueConfig{
|
|
Interval: time.Duration(r.getInt("sync_queue_interval_seconds")) * time.Second,
|
|
BatchSize: r.getInt("sync_queue_batch_size"),
|
|
}
|
|
}
|
|
|
|
// WorkerPoolConfig bundles worker count and queue capacity. Read at startup;
|
|
// changes require a restart.
|
|
type WorkerPoolConfig struct {
|
|
Size int
|
|
QueueCap int
|
|
}
|
|
|
|
// WorkerPoolConfig returns the active worker pool configuration.
|
|
func (r *SettingsRegistry) WorkerPoolConfig() WorkerPoolConfig {
|
|
return WorkerPoolConfig{
|
|
Size: r.getInt("worker_pool_size"),
|
|
QueueCap: r.getInt("worker_queue_cap"),
|
|
}
|
|
}
|
|
|
|
// SettingEntry exposes one setting's metadata + current value, for the admin UI/API.
|
|
type SettingEntry struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
Type string `json:"type"`
|
|
Min string `json:"min,omitempty"`
|
|
Max string `json:"max,omitempty"`
|
|
RequiresRestart bool `json:"requires_restart"`
|
|
Category string `json:"category"`
|
|
Group string `json:"group"`
|
|
Description string `json:"description"`
|
|
IsDefault bool `json:"is_default"`
|
|
}
|
|
|
|
// All returns metadata + current values for every known setting, grouped by
|
|
// the in-memory cache (which reflects the DB after Load/Reload).
|
|
func (r *SettingsRegistry) All() []SettingEntry {
|
|
r.mu.RLock()
|
|
vals := make(map[string]string, len(r.values))
|
|
for k, v := range r.values {
|
|
vals[k] = v
|
|
}
|
|
r.mu.RUnlock()
|
|
out := make([]SettingEntry, 0, len(SettingDefaults))
|
|
for _, d := range SettingDefaults {
|
|
v, ok := vals[d.Key]
|
|
if !ok {
|
|
v = d.Value
|
|
}
|
|
out = append(out, SettingEntry{
|
|
Key: d.Key,
|
|
Value: v,
|
|
Type: d.Type,
|
|
Min: d.Min,
|
|
Max: d.Max,
|
|
RequiresRestart: d.RequiresRestart,
|
|
Category: d.Category,
|
|
Group: d.Group,
|
|
Description: d.Description,
|
|
IsDefault: v == d.Value,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// LookupDefault returns the compiled-in SettingDefault for a key (ok=false if unknown).
|
|
func LookupDefault(key string) (SettingDefault, bool) {
|
|
d, ok := defaultBy[key]
|
|
return d, ok
|
|
}
|