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.
This commit is contained in:
2026-08-10 08:00:52 -04:00
parent 64ad10b3f2
commit bc47450653
8 changed files with 633 additions and 19 deletions
+41 -5
View File
@@ -45,11 +45,47 @@ CREATE TABLE IF NOT EXISTS system_settings (
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
-- Insert default system settings -- Extend system_settings with typed metadata so it can back the admin UI's
INSERT INTO system_settings (setting_key, setting_value, description) VALUES -- configurable tunables. All columns are nullable for backward compatibility
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'), -- with the original three rows and any pre-existing data.
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'), ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS setting_type VARCHAR(20);
('default_timezone', 'UTC', 'System default timezone') 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; ON CONFLICT (setting_key) DO NOTHING;
-- Create refresh_tokens table -- Create refresh_tokens table
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.30.0 // sqlc v1.31.1
package database package database
+7 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.30.0 // sqlc v1.31.1
package database package database
@@ -489,6 +489,11 @@ type SystemSettings struct {
SettingValue string `db:"setting_value" json:"setting_value"` SettingValue string `db:"setting_value" json:"setting_value"`
Description pgtype.Text `db:"description" json:"description"` Description pgtype.Text `db:"description" json:"description"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` 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 { type UnlinkedBooks struct {
@@ -532,3 +537,4 @@ type Users struct {
Timezone pgtype.Text `db:"timezone" json:"timezone"` Timezone pgtype.Text `db:"timezone" json:"timezone"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
} }
+5 -2
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.30.0 // sqlc v1.31.1
package database package database
@@ -26,7 +26,7 @@ type Querier interface {
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error) CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
// Cleanup expired OPDS tokens // Cleanup expired OPDS tokens
CleanupExpiredOpdsTokens(ctx context.Context) error 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 ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
@@ -137,6 +137,7 @@ type Querier interface {
// Get all system config // Get all system config
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error) GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
// Get collection // Get collection
@@ -278,6 +279,7 @@ type Querier interface {
GetSystemConfig(ctx context.Context, key string) (SystemConfig, error) GetSystemConfig(ctx context.Context, key string) (SystemConfig, error)
// System Settings queries // System Settings queries
GetSystemSetting(ctx context.Context, settingKey string) (string, error) GetSystemSetting(ctx context.Context, settingKey string) (string, error)
GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error)
GetSystemTimezone(ctx context.Context) (string, error) GetSystemTimezone(ctx context.Context) (string, error)
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
// Get universal progress for a book // Get universal progress for a book
@@ -428,6 +430,7 @@ type Querier interface {
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error) UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error) UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
} }
var _ Querier = (*Queries)(nil) var _ Querier = (*Queries)(nil)
+114 -4
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.30.0 // sqlc v1.31.1
// source: queries.sql // source: queries.sql
package database package database
@@ -186,11 +186,11 @@ func (q *Queries) CleanupExpiredOpdsTokens(ctx context.Context) error {
} }
const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec 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 { func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error {
_, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens) _, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens, dollar_1)
return err return err
} }
@@ -2258,6 +2258,41 @@ func (q *Queries) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSetti
return items, nil 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 const GetAnnotationsForBook = `-- name: GetAnnotationsForBook :many
SELECT SELECT
mh.id, mh.id,
@@ -6587,6 +6622,28 @@ func (q *Queries) GetSystemSetting(ctx context.Context, settingKey string) (stri
return setting_value, err 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 const GetSystemTimezone = `-- name: GetSystemTimezone :one
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone' 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 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
}
+21 -1
View File
@@ -373,9 +373,29 @@ SELECT setting_value FROM system_settings WHERE setting_key = $1;
-- name: UpdateSystemSetting :exec -- name: UpdateSystemSetting :exec
UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1; 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 -- name: GetAllSystemSettings :many
SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key; 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 -- name: CreateMediaRating :one
INSERT INTO media_ratings (media_item_id, user_id, rating) INSERT INTO media_ratings (media_item_id, user_id, rating)
VALUES ($1, $2, $3) 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; UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL;
-- name: CleanupExpiredRefreshTokens :exec -- 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 -- FORMAT DETECTION & PROGRESS
+342
View File
@@ -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
}
@@ -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")
}
}