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)