From bc474506538f0b173e7085a8a944b74ce736a719 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 08:00:52 -0400 Subject: [PATCH] feat(db): typed tunable system settings + SettingsRegistry 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. --- database/schema/schema.sql | 46 ++- internal/database/db.go | 2 +- internal/database/models.go | 18 +- internal/database/querier.go | 7 +- internal/database/queries.sql.go | 118 ++++++- internal/database/queries/queries.sql | 22 +- internal/database/settings_registry.go | 342 ++++++++++++++++++++ internal/database/settings_registry_test.go | 97 ++++++ 8 files changed, 633 insertions(+), 19 deletions(-) create mode 100644 internal/database/settings_registry.go create mode 100644 internal/database/settings_registry_test.go diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 0629cf6..af2f44f 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -45,11 +45,47 @@ CREATE TABLE IF NOT EXISTS system_settings ( updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); --- Insert default system settings -INSERT INTO system_settings (setting_key, setting_value, description) VALUES -('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'), -('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'), -('default_timezone', 'UTC', 'System default timezone') +-- Extend system_settings with typed metadata so it can back the admin UI's +-- configurable tunables. All columns are nullable for backward compatibility +-- with the original three rows and any pre-existing data. +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS setting_type VARCHAR(20); +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS min_value TEXT; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS max_value TEXT; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS requires_restart BOOLEAN DEFAULT FALSE; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS category VARCHAR(40); + +-- Insert default system settings (original scan/timezone rows + tunables). +-- Values match the previous hardcoded literals, so behavior is unchanged on upgrade. +-- ON CONFLICT DO NOTHING preserves any admin-modified values. +INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES +('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'), +('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'), +('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'), +-- security / auth (live) +('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'), +('password_min_length', '8', 'Minimum password length', 'int', '1', '128', FALSE, 'security'), +('password_require_upper', 'true', 'Require at least one uppercase letter (A-Z)', 'bool', NULL, NULL, FALSE, 'security'), +('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'), +('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'), +('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'), +-- security / auth (restart required) +('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'), +('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'), +('login_lockout_minutes', '15', 'Lockout duration after too many failed logins', 'int', '1', '10080', TRUE, 'security'), +-- api (live) +('opds_default_page_size', '50', 'Default OPDS page size', 'int', '1', '500', FALSE, 'api'), +('opds_max_page_size', '200', 'Maximum OPDS page size', 'int', '1', '1000', FALSE, 'api'), +('device_rate_sync_per_min', '60', 'Device sync requests per minute', 'int', '1', '10000', FALSE, 'api'), +('device_rate_progress_per_min', '120', 'Device progress requests per minute', 'int', '1', '10000', FALSE, 'api'), +('device_rate_metadata_per_min', '30', 'Device metadata requests per minute', 'int', '1', '10000', FALSE, 'api'), +-- sync / performance (live) +('annotation_tombstone_ttl_days', '30', 'How long deleted annotations are kept before purge', 'int', '1', '3650', FALSE, 'sync'), +('conversion_cache_ttl_hours', '24', 'How long converted (kepub) files are cached', 'int', '1', '720', FALSE, 'performance'), +-- sync / performance (restart required) +('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'), +('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'), +('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'), +('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance') ON CONFLICT (setting_key) DO NOTHING; -- Create refresh_tokens table diff --git a/internal/database/db.go b/internal/database/db.go index bdf4241..486aa36 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package database diff --git a/internal/database/models.go b/internal/database/models.go index fa7d6a0..714627a 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package database @@ -484,11 +484,16 @@ type SystemConfig struct { } type SystemSettings struct { - ID pgtype.UUID `db:"id" json:"id"` - SettingKey string `db:"setting_key" json:"setting_key"` - SettingValue string `db:"setting_value" json:"setting_value"` - Description pgtype.Text `db:"description" json:"description"` - UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + ID pgtype.UUID `db:"id" json:"id"` + SettingKey string `db:"setting_key" json:"setting_key"` + SettingValue string `db:"setting_value" json:"setting_value"` + Description pgtype.Text `db:"description" json:"description"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + SettingType pgtype.Text `db:"setting_type" json:"setting_type"` + MinValue pgtype.Text `db:"min_value" json:"min_value"` + MaxValue pgtype.Text `db:"max_value" json:"max_value"` + RequiresRestart pgtype.Bool `db:"requires_restart" json:"requires_restart"` + Category pgtype.Text `db:"category" json:"category"` } type UnlinkedBooks struct { @@ -532,3 +537,4 @@ type Users struct { Timezone pgtype.Text `db:"timezone" json:"timezone"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` } + diff --git a/internal/database/querier.go b/internal/database/querier.go index 5d8fbc2..28c4b45 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package database @@ -26,7 +26,7 @@ type Querier interface { CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error) // Cleanup expired OPDS tokens CleanupExpiredOpdsTokens(ctx context.Context) error - CleanupExpiredRefreshTokens(ctx context.Context) error + CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error @@ -137,6 +137,7 @@ type Querier interface { // Get all system config GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error) + GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error) // Get collection @@ -278,6 +279,7 @@ type Querier interface { GetSystemConfig(ctx context.Context, key string) (SystemConfig, error) // System Settings queries GetSystemSetting(ctx context.Context, settingKey string) (string, error) + GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error) GetSystemTimezone(ctx context.Context) (string, error) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error) // Get universal progress for a book @@ -428,6 +430,7 @@ type Querier interface { UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error) UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error) + UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error) } var _ Querier = (*Queries)(nil) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 91961c5..076afdd 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: queries.sql package database @@ -186,11 +186,11 @@ func (q *Queries) CleanupExpiredOpdsTokens(ctx context.Context) error { } const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec -DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days') +DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision)) ` -func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context) error { - _, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens) +func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error { + _, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens, dollar_1) return err } @@ -2258,6 +2258,41 @@ func (q *Queries) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSetti return items, nil } +const GetAllSystemSettingsFull = `-- name: GetAllSystemSettingsFull :many +SELECT id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category FROM system_settings ORDER BY category, setting_key +` + +func (q *Queries) GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error) { + rows, err := q.db.Query(ctx, GetAllSystemSettingsFull) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SystemSettings{} + for rows.Next() { + var i SystemSettings + if err := rows.Scan( + &i.ID, + &i.SettingKey, + &i.SettingValue, + &i.Description, + &i.UpdatedAt, + &i.SettingType, + &i.MinValue, + &i.MaxValue, + &i.RequiresRestart, + &i.Category, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetAnnotationsForBook = `-- name: GetAnnotationsForBook :many SELECT mh.id, @@ -6587,6 +6622,28 @@ func (q *Queries) GetSystemSetting(ctx context.Context, settingKey string) (stri return setting_value, err } +const GetSystemSettingFull = `-- name: GetSystemSettingFull :one +SELECT id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category FROM system_settings WHERE setting_key = $1 +` + +func (q *Queries) GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error) { + row := q.db.QueryRow(ctx, GetSystemSettingFull, settingKey) + var i SystemSettings + err := row.Scan( + &i.ID, + &i.SettingKey, + &i.SettingValue, + &i.Description, + &i.UpdatedAt, + &i.SettingType, + &i.MinValue, + &i.MaxValue, + &i.RequiresRestart, + &i.Category, + ) + return i, err +} + const GetSystemTimezone = `-- name: GetSystemTimezone :one SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone' ` @@ -12280,3 +12337,56 @@ func (q *Queries) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSett ) return i, err } + +const UpsertSystemSetting = `-- name: UpsertSystemSetting :one +INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (setting_key) DO UPDATE +SET setting_value = EXCLUDED.setting_value, + description = EXCLUDED.description, + setting_type = EXCLUDED.setting_type, + min_value = EXCLUDED.min_value, + max_value = EXCLUDED.max_value, + requires_restart = EXCLUDED.requires_restart, + category = EXCLUDED.category, + updated_at = NOW() +RETURNING id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category +` + +type UpsertSystemSettingParams struct { + SettingKey string `db:"setting_key" json:"setting_key"` + SettingValue string `db:"setting_value" json:"setting_value"` + Description pgtype.Text `db:"description" json:"description"` + SettingType pgtype.Text `db:"setting_type" json:"setting_type"` + MinValue pgtype.Text `db:"min_value" json:"min_value"` + MaxValue pgtype.Text `db:"max_value" json:"max_value"` + RequiresRestart pgtype.Bool `db:"requires_restart" json:"requires_restart"` + Category pgtype.Text `db:"category" json:"category"` +} + +func (q *Queries) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error) { + row := q.db.QueryRow(ctx, UpsertSystemSetting, + arg.SettingKey, + arg.SettingValue, + arg.Description, + arg.SettingType, + arg.MinValue, + arg.MaxValue, + arg.RequiresRestart, + arg.Category, + ) + var i SystemSettings + err := row.Scan( + &i.ID, + &i.SettingKey, + &i.SettingValue, + &i.Description, + &i.UpdatedAt, + &i.SettingType, + &i.MinValue, + &i.MaxValue, + &i.RequiresRestart, + &i.Category, + ) + return i, err +} diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index eb1220a..fb8e871 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -373,9 +373,29 @@ SELECT setting_value FROM system_settings WHERE setting_key = $1; -- name: UpdateSystemSetting :exec UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1; +-- name: UpsertSystemSetting :one +INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (setting_key) DO UPDATE +SET setting_value = EXCLUDED.setting_value, + description = EXCLUDED.description, + setting_type = EXCLUDED.setting_type, + min_value = EXCLUDED.min_value, + max_value = EXCLUDED.max_value, + requires_restart = EXCLUDED.requires_restart, + category = EXCLUDED.category, + updated_at = NOW() +RETURNING *; + +-- name: GetSystemSettingFull :one +SELECT * FROM system_settings WHERE setting_key = $1; + -- name: GetAllSystemSettings :many SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key; +-- name: GetAllSystemSettingsFull :many +SELECT * FROM system_settings ORDER BY category, setting_key; + -- name: CreateMediaRating :one INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1, $2, $3) @@ -1007,7 +1027,7 @@ UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1; UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL; -- name: CleanupExpiredRefreshTokens :exec -DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days'); +DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision)); -- ============================================ -- FORMAT DETECTION & PROGRESS diff --git a/internal/database/settings_registry.go b/internal/database/settings_registry.go new file mode 100644 index 0000000..3f6e905 --- /dev/null +++ b/internal/database/settings_registry.go @@ -0,0 +1,342 @@ +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 +} diff --git a/internal/database/settings_registry_test.go b/internal/database/settings_registry_test.go new file mode 100644 index 0000000..07a455d --- /dev/null +++ b/internal/database/settings_registry_test.go @@ -0,0 +1,97 @@ +package database + +import ( + "strconv" + "testing" +) + +// TestSettingDefaults ensures every seeded setting has a compiled default with +// a valid value for its declared type. This guards against typos that would +// silently fall back at runtime. +func TestSettingDefaults(t *testing.T) { + if len(SettingDefaults) == 0 { + t.Fatal("SettingDefaults is empty") + } + for _, d := range SettingDefaults { + if d.Key == "" { + t.Errorf("default has empty key: %+v", d) + continue + } + switch d.Type { + case SettingTypeInt: + if _, err := strconv.Atoi(d.Value); err != nil { + t.Errorf("int setting %s default %q is not an int: %v", d.Key, d.Value, err) + } + if d.Min != "" { + if _, err := strconv.Atoi(d.Min); err != nil { + t.Errorf("int setting %s min %q is not an int", d.Key, d.Min) + } + } + if d.Max != "" { + if _, err := strconv.Atoi(d.Max); err != nil { + t.Errorf("int setting %s max %q is not an int", d.Key, d.Max) + } + } + case SettingTypeBool: + if _, err := strconv.ParseBool(d.Value); err != nil { + t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value) + } + case SettingTypeString: + if d.Value == "" { + t.Errorf("string setting %s has empty default", d.Key) + } + default: + t.Errorf("setting %s has unknown type %q", d.Key, d.Type) + } + } +} + +// TestSettingsRegistryGetIntClamping verifies that out-of-range DB values are +// clamped to the declared min/max, and that garbage falls back to the default. +func TestSettingsRegistryGetIntClamping(t *testing.T) { + r := &SettingsRegistry{values: map[string]string{}, q: nil} + + // Seed with an over-max value; expect clamping to the max (3600). + r.values["scan_poll_interval_seconds"] = "999999" + if got := r.ScanPollInterval(); got.Seconds() != 3600 { + t.Errorf("expected clamp to 3600, got %v", got) + } + + // Seed with an under-min value; expect clamp to min (1). + r.values["scan_poll_interval_seconds"] = "0" + if got := r.ScanPollInterval(); got.Seconds() != 1 { + t.Errorf("expected clamp to 1, got %v", got) + } + + // Seed with garbage; expect fallback to default (60). + r.values["scan_poll_interval_seconds"] = "not-a-number" + if got := r.ScanPollInterval(); got.Seconds() != 60 { + t.Errorf("expected fallback default 60, got %v", got) + } +} + +// TestSettingsRegistryGetBoolFallback verifies bool parsing and fallback. +func TestSettingsRegistryGetBoolFallback(t *testing.T) { + r := &SettingsRegistry{values: map[string]string{}, q: nil} + + r.values["auto_scan_enabled"] = "true" + if !r.AutoScanEnabled() { + t.Error("expected true") + } + + r.values["auto_scan_enabled"] = "garbage" + // garbage falls back to default ("true") + if !r.AutoScanEnabled() { + t.Error("expected fallback to default true") + } +} + +// TestLookupDefaultUnknownKey verifies unknown keys return ok=false. +func TestLookupDefaultUnknownKey(t *testing.T) { + if _, ok := LookupDefault("does_not_exist"); ok { + t.Error("expected ok=false for unknown key") + } + if _, ok := LookupDefault("session_duration_seconds"); !ok { + t.Error("expected ok=true for known key") + } +}