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:
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user