feat(app): wire settings registry into startup and admin routes

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.
This commit is contained in:
2026-08-10 08:02:41 -04:00
parent 885f6d8187
commit 537330e7e0
3 changed files with 60 additions and 7 deletions
+24 -4
View File
@@ -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,
+31 -1
View File
@@ -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, `<span style="color: var(--status-danger);">settings unavailable</span>`)
}
resp, err := cfg.SystemSettingsHandler.ApplySetting(ctx, key, value)
if err != nil {
return c.HTML(http.StatusBadRequest, fmt.Sprintf(`<span style="color: var(--status-danger);">%s</span>`, 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(`<span style="color: %s;">%s</span>`, color, msg))
})
}
+5 -2
View File
@@ -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)