Files
bookhoard/cmd/server/main.go
T
john-okeefe 4716790564 fix: OPDS base_url placeholder bug + setup gate requires base_url
Three bugs fixed:

1. Schema seeded base_url with fake placeholder 'bookhoard.example.com'.
   Removed seed; startup now seeds from BASE_URL env var only if DB row
   is empty (admin changes persist across restarts). One-time UPDATE
   clears the placeholder in existing installs.

2. config.GetBaseURL() had a broken type assertion (local SystemConfigRow
   vs database.SystemConfig) that always failed, returning . Admin panel
   showed env var fallback instead of actual DB value. Fixed with a
   function-type getter that properly wraps the DB query.

3. OPDS handler read base_url only from DB with no fallback. When DB had
   the placeholder, all feed links pointed to an unreachable domain,
   breaking KOReader search/download. Added deriveBaseURL() helper that
   falls back to the request Host/scheme when DB value is empty.

Setup gate improvements:
- isSetupComplete now requires both admin user AND non-empty base_url
- Setup middleware no longer exempts all /api/ routes; only allows
  /api/auth/register, /api/auth/login, /api/system/config before setup
  is complete. All other API routes get 503.
- Cache invalidated when base_url is saved via admin settings

Dev workflow:
- New bruno/NewDevDBSetup/SetBaseUrl.yml for dev DB setup
- NewDB.sh runs SetBaseUrl between RegisterUser and CreateEbookLibrary
2026-08-06 13:02:35 -04:00

232 lines
8.4 KiB
Go

package main
import (
"bookhoard/internal/app"
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/middleware"
ratelimit "bookhoard/internal/middleware"
"bookhoard/internal/router"
"bookhoard/internal/services"
"bookhoard/internal/sync"
"context"
"log"
"time"
"github.com/go-playground/validator/v10"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
echomiddleware "github.com/labstack/echo/v5/middleware"
)
// CustomValidator wraps the go-playground validator
type CustomValidator struct {
validator *validator.Validate
}
func (cv *CustomValidator) Validate(i interface{}) error {
return cv.validator.Struct(i)
}
func main() {
cfg := config.LoadConfig()
dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL())
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
defer dbPool.Close()
queries := database.New(dbPool)
// Initialize database schema
log.Println("🔧 Ensuring database schema is initialized...")
ctx := context.Background()
if err := database.Initialize(ctx, dbPool); err != nil {
log.Fatal("❌ Database schema initialization failed:", err)
}
log.Println("✅ Database schema initialized and verified, starting server...")
// 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 != "" {
_, err = dbPool.Exec(ctx, `
INSERT INTO system_config (key, value)
VALUES ('base_url', $1)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value
WHERE system_config.value = ''
`, cfg.BaseURL)
if err != nil {
log.Printf("⚠️ Could not seed base_url: %v", err)
} else {
// Also seed derived URLs
for key, suffix := range map[string]string{
"opds_base_url": "/opds",
"api_base_url": "/api",
} {
_, _ = dbPool.Exec(ctx, `
INSERT INTO system_config (key, value)
VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value
WHERE system_config.value = ''
`, key, cfg.BaseURL+suffix)
}
}
}
// Create login attempt tracker: 5 failed attempts = 15 minute lockout
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
sidecarHandler := handlers.NewSidecarHandler(queries, cfg)
libraryHandler := handlers.NewLibraryHandler(queries)
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
// Create WebSocket connection manager
connManager := sync.NewConnectionManager()
progressService := sync.NewProgressService(queries, connManager)
annotationService := sync.NewAnnotationService(queries, connManager)
tombstonePurgerCancel := annotationService.StartTombstonePurger()
defer tombstonePurgerCancel()
queueProcessor := sync.NewSyncQueueProcessor(queries)
queueProcessor.SetProgressService(progressService)
queueProcessor.SetAnnotationService(annotationService)
// Create library service
libraryService := services.NewLibraryService(queries)
// Sync Go AllowedExtensions into DB so API clients see correct extensions
libraryService.SyncAllowedExtensions(context.Background())
// Create worker for background tasks
worker := services.NewWorker(3, connManager)
services.WorkerInstance = worker
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
koreaderHandler.SetProgressService(progressService)
koreaderHandler.SetAnnotationService(annotationService)
koreaderHandler.SetLibraryService(libraryService)
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
conflictHandler := handlers.NewConflictHandler(queries, connManager)
analyticsHandler := handlers.NewAnalyticsHandler(queries)
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
seriesHandler := handlers.NewSeriesHandler(queries)
filtersHandler := handlers.NewFiltersHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
mediaHandler.SetProgressService(progressService)
mediaHandler.SetAnnotationService(annotationService)
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
jobsHandler := handlers.NewJobsHandler(queries, worker)
e := echo.New()
// Set up validator
v := validator.New()
// Register custom password complexity validator
if err := ratelimit.RegisterPasswordValidation(v); err != nil {
log.Fatal("Failed to register password validator:", err)
}
e.Validator = &CustomValidator{validator: v}
// Middleware
e.Use(echomiddleware.RequestLogger())
e.Use(echomiddleware.Recover())
e.Use(echomiddleware.CORSWithConfig(echomiddleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: false,
}))
e.Use(ratelimit.RequestTracingMiddleware(cfg))
// Rate limiter for auth endpoints
// rateLimiterConfig := ratelimit.RateLimiterConfig{
// Enabled: cfg.RateLimitEnabled,
// RequestsPerMinute: cfg.RequestsPerMinute,
// CleanupInterval: 5 * time.Minute,
// }
// rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)
// rateLimitMiddleware := ratelimit.RateLimiterMiddleware(rateLimiter) // Now in router/auth.go
// ========================================================================
// ROUTER REGISTRATION - Migrate routes to internal/router/ package
// ========================================================================
routerConfig := &router.Config{
Echo: e,
Queries: queries,
Cfg: cfg,
DBPool: dbPool,
AuthHandler: authHandler,
LibraryHandler: libraryHandler,
DeviceHandler: deviceHandler,
MediaHandler: mediaHandler,
MatchingHandler: matchingHandler,
ProcessingIssuesHandler: processingIssuesHandler,
KOReaderHandler: koreaderHandler,
WSHandler: wsHandler,
ConflictHandler: conflictHandler,
AnalyticsHandler: analyticsHandler,
QueueHandler: queueHandler,
CollectionHandler: collectionHandler,
FiltersHandler: filtersHandler,
DashboardHandler: dashboardHandler,
DashboardService: dashboardService,
SeriesHandler: seriesHandler,
OPDSHandler: opdsHandler,
Worker: worker,
SystemSettingsHandler: systemSettingsHandler,
SidecarHandler: sidecarHandler,
ConnManager: connManager,
QueueProcessor: queueProcessor,
ProgressService: progressService,
AnnotationService: annotationService,
DeviceAuthMiddleware: deviceAuthMiddleware,
JobsHandler: jobsHandler,
LoginTracker: loginAttemptTracker,
LibraryService: libraryService,
}
// Register all routes and get ebook handler
_ = router.RegisterRoutes(routerConfig)
// ========================================================================
// APPLICATION LIFECYCLE MANAGEMENT
// ========================================================================
// Create app with lifecycle management
application := app.New(e)
// ========================================================================
// START SERVER (managed by app lifecycle)
// ========================================================================
log.Printf("Starting server on port %s", cfg.ServerPort)
// Start HTTP server
if err := application.StartServer(":" + cfg.ServerPort); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
// Start application lifecycle (blocks until shutdown signal)
if err := application.Start(); err != nil {
log.Fatalf("Application error: %v", err)
}
}