From 537330e7e03e0045effabff812da8487a27cbfc0 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 08:02:41 -0400 Subject: [PATCH] feat(app): wire settings registry into startup and admin routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Construct the SettingsRegistry at boot, load it, and thread it through every consumer so the configurable values take effect and stay cached. cmd/server/main.go: - Build the registry from the Queries handle and Load() it right after schema init; a load failure logs and continues (getters fall back to compiled defaults, so startup is never blocked). - Wire the registry into the package-level password validator (SetDefaultPasswordSettings) and call SetSettings on every handler/ service that reads tunables: AuthHandler, DeviceAuthMiddleware, OPDSHandler, SidecarHandler, SystemSettingsHandler, AnnotationService, ConversionService. - Source the restart-time values from the registry: login lockout (max attempts + duration) feeds NewLoginAttemptTracker, and the new NewSyncQueueProcessorWithConfig / NewWorkerWithConfig take the sync queue and worker pool configs. router.go: - Config gains a Settings *database.SettingsRegistry field. - The global auth rate limiter now reads RequestsPerMinute from registry.AuthRateLimit() (env stays as the enabled/disabled switch and as the fallback if the registry is unset). admin_library.go: - The HTMX scan-settings save endpoint reloads the registry after writing so the change is visible without a page reload. - Add PUT /admin/settings/tunable: a small HTMX endpoint that calls SystemSettingsHandler.ApplySetting and returns a colored status snippet ("Saved" or "Saved — restart required") for the admin UI's per-row forms. --- cmd/server/main.go | 28 ++++++++++++++++++++++++---- internal/router/admin_library.go | 32 +++++++++++++++++++++++++++++++- internal/router/router.go | 7 +++++-- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index b470e10..cde17fd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -50,6 +50,16 @@ func main() { } log.Println("✅ Database schema initialized and verified, starting server...") + // Load tunable settings from the DB into the registry. All values fall back + // to compiled defaults if a row is missing, so this never blocks startup. + registry := database.NewSettingsRegistry(queries) + if err := registry.Load(ctx); err != nil { + log.Printf("⚠️ Could not load system settings (using defaults): %v", err) + } + // Wire the registry into the package-level password validator so live + // rule changes apply to the echo struct-tag validator and ValidatePassword. + middleware.SetDefaultPasswordSettings(registry) + // Seed base_url from env var if not already configured. Uses conditional // UPDATE so admin-set values are never overwritten on restart. if cfg.BaseURL != "" { @@ -79,15 +89,20 @@ func main() { } } - // Create login attempt tracker: 5 failed attempts = 15 minute lockout - loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute) + // Create login attempt tracker from configured (or default) lockout policy. + loginMaxAttempts, loginLockout := registry.LoginLockout() + loginAttemptTracker := ratelimit.NewLoginAttemptTracker(loginMaxAttempts, loginLockout, 5*time.Minute) authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker) + authHandler.SetSettings(registry) systemSettingsHandler := handlers.NewSystemSettingsHandler(queries) + systemSettingsHandler.SetSettings(registry) sidecarHandler := handlers.NewSidecarHandler(queries, cfg) + sidecarHandler.SetSettings(registry) libraryHandler := handlers.NewLibraryHandler(queries) deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg) deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries) + deviceAuthMiddleware.SetSettings(registry) processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries) // Create WebSocket connection manager @@ -95,10 +110,11 @@ func main() { progressService := sync.NewProgressService(queries, connManager) annotationService := sync.NewAnnotationService(queries, connManager) + annotationService.SetSettings(registry) tombstonePurgerCancel := annotationService.StartTombstonePurger() defer tombstonePurgerCancel() - queueProcessor := sync.NewSyncQueueProcessor(queries) + queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize) queueProcessor.SetProgressService(progressService) queueProcessor.SetAnnotationService(annotationService) @@ -109,7 +125,8 @@ func main() { libraryService.SyncAllowedExtensions(context.Background()) // Create worker for background tasks - worker := services.NewWorker(3, connManager) + workerCfg := registry.WorkerPoolConfig() + worker := services.NewWorkerWithConfig(workerCfg.Size, workerCfg.QueueCap, connManager) services.WorkerInstance = worker koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor) @@ -122,7 +139,9 @@ func main() { queueHandler := handlers.NewQueueHandler(queries, queueProcessor) conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub") + conversionService.SetSettings(registry) opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService) + opdsHandler.SetSettings(registry) collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager) dashboardService := services.NewDashboardService(queries) @@ -175,6 +194,7 @@ func main() { Echo: e, Queries: queries, Cfg: cfg, + Settings: registry, DBPool: dbPool, AuthHandler: authHandler, LibraryHandler: libraryHandler, diff --git a/internal/router/admin_library.go b/internal/router/admin_library.go index 46ad52c..4d4fe9e 100644 --- a/internal/router/admin_library.go +++ b/internal/router/admin_library.go @@ -1,11 +1,12 @@ package router import ( - "bytes" "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/templates" + "bytes" "context" + "fmt" "log" "net/http" "os" @@ -406,6 +407,11 @@ func registerAdminSettingsRoutes(cfg *Config, frontendProtected *echo.Group) { SettingValue: strconv.Itoa(interval), }) + // Refresh the registry cache so the change is visible immediately. + if cfg.Settings != nil { + cfg.Settings.Reload(ctx) + } + scanSettings := templates.ScanSettingsData{ AutoScanEnabled: autoScan, ScanPollIntervalSeconds: interval, @@ -414,4 +420,28 @@ func registerAdminSettingsRoutes(cfg *Config, frontendProtected *echo.Group) { _ = templates.ScanSettingsSection(scanSettings).Render(ctx, &buf) return c.HTML(http.StatusOK, buf.String()) }) + + // HTMX endpoint for saving a single tunable setting. Returns a small HTML + // status snippet rendered into the row's status span. + g.PUT("/admin/settings/tunable", func(c *echo.Context) error { + ctx := c.Request().Context() + key := c.FormValue("key") + value := c.FormValue("value") + + if cfg.SystemSettingsHandler == nil { + return c.HTML(http.StatusServiceUnavailable, `settings unavailable`) + } + resp, err := cfg.SystemSettingsHandler.ApplySetting(ctx, key, value) + if err != nil { + return c.HTML(http.StatusBadRequest, fmt.Sprintf(`%s`, err.Error())) + } + + color := "var(--status-success)" + msg := "Saved" + if resp.ReloadRequired { + color = "var(--status-warning)" + msg = "Saved — restart required" + } + return c.HTML(http.StatusOK, fmt.Sprintf(`%s`, color, msg)) + }) } diff --git a/internal/router/router.go b/internal/router/router.go index 4c956ab..318abfa 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -39,6 +39,7 @@ type Config struct { Echo *echo.Echo Queries *database.Queries Cfg *config.Config + Settings *database.SettingsRegistry DBPool interface{} // pgxpool.Pool interface AuthHandler *handlers.AuthHandler LibraryHandler *handlers.LibraryHandler @@ -211,10 +212,12 @@ func RegisterRoutes(cfg *Config) *handlers.Handler { // Setup redirect middleware - must run before all routes e.Pre(setupRedirectMiddleware(cfg)) - // Rate limiter + // Rate limiter. The per-minute value comes from the settings registry (DB); + // the enabled flag stays env-driven since disabling rate limiting is a + // deployment-time decision, not a runtime tunable. rateLimiterConfig := ratelimit.RateLimiterConfig{ Enabled: cfg.Cfg.RateLimitEnabled, - RequestsPerMinute: cfg.Cfg.RequestsPerMinute, + RequestsPerMinute: cfg.Settings.AuthRateLimit(), CleanupInterval: 5 * time.Minute, } rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)