Files
bookhoard/internal/router/router.go
T
john-okeefe 03cb4c7869
Release / build-and-push (push) Successful in 2m48s
feat(admin): startup hash backfill and hash-conflict resolution API
Complete the SHA-256 lifecycle for preexisting databases: items
imported before hashing existed get hashed automatically, and any
content duplicates discovered in the process land on the new admin
Hash Conflicts page for an explicit keep/merge decision.

HashBackfillService (runs once 30s after startup, independent of
auto-scan):
- hashes every media_items row where file_sha256 IS NULL, resolving
  each path through LibraryService; per-item failures are logged and
  skipped so one unreadable file cannot block the pass
- no-op once everything is hashed (logged and skipped)
- finishes with a conflict sweep flagging every content-duplicate
  group via FindHashConflictGroups + CreateHashConflict; the sweep
  runs after the per-item pass because a preexisting pair only
  becomes detectable once both sides have their hash

API (admin-only):
- GET /api/admin/hash-conflicts - pending groups with member items
  and usage counts
- POST /api/admin/hash-conflicts/:id/resolve - action=keep_all, or
  action=keep with keep_uuid: validates the uuid belongs to the
  group, re-parents every other copy's child rows onto the kept item
  (reparent_media_item_children), deletes the losers, and records
  the resolution + resolving admin; accepts form or JSON bodies and
  returns the htmx resolved fragment

Page route /admin/hash-conflicts (admin-only) renders the template
with hydrated conflict data; HashConflictsHandler wired into the
router Config and constructed in main.

Verified end-to-end against the live database: duplicate detection,
pending listing, keep_all resolution, merge path (re-parent +
delete), and - critically - a resolved group is not re-flagged by a
later sweep (upsert no-op). Database restored afterward.
2026-08-14 08:53:01 -04:00

322 lines
10 KiB
Go

package router
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/middleware"
ratelimit "bookhoard/internal/middleware"
"bookhoard/internal/services"
"bookhoard/internal/sync"
"bookhoard/templates"
"bytes"
"context"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
echojwt "github.com/labstack/echo-jwt/v5"
"github.com/labstack/echo/v5"
)
// CustomValidator wraps the go-playground validator
type CustomValidator struct {
validator *validator.Validate
}
func (cv *CustomValidator) Validate(i interface{}) error {
return cv.validator.Struct(i)
}
// Config holds all dependencies needed for route registration
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
DeviceHandler *handlers.DeviceHandler
MediaHandler *handlers.MediaHandler
MatchingHandler *handlers.MatchingHandler
ProcessingIssuesHandler *handlers.ProcessingIssuesHandler
HashConflictsHandler *handlers.HashConflictsHandler
KOReaderHandler *handlers.KOReaderHandler
WSHandler *handlers.WSHandler
ConflictHandler *handlers.ConflictHandler
AnalyticsHandler *handlers.AnalyticsHandler
QueueHandler *handlers.QueueHandler
CollectionHandler *handlers.CollectionHandler
Worker *services.Worker
FiltersHandler *handlers.FiltersHandler
DashboardHandler *handlers.DashboardHandler
DashboardService *services.DashboardService
SeriesHandler *handlers.SeriesHandler
OPDSHandler *handlers.OPDSHandler
SystemSettingsHandler *handlers.SystemSettingsHandler
ConnManager *sync.ConnectionManager
QueueProcessor *sync.SyncQueueProcessor
ProgressService *sync.ProgressService
AnnotationService *sync.AnnotationService
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
LoginTracker *ratelimit.LoginAttemptTracker
ScannerHandler *handlers.Handler
JobsHandler *handlers.JobsHandler
SidecarHandler *handlers.SidecarHandler
ReaderHandler *handlers.ReaderHandler
LibraryService *services.LibraryService
}
// getBaseURL returns the configured base URL from the database, falling back to
// the env var / config default. Uses a closure to adapt the database query to
// config.SystemConfigGetter.
func (cfg *Config) getBaseURL(ctx context.Context) string {
getter := func(ctx context.Context, key string) (string, error) {
row, err := cfg.Queries.GetSystemConfig(ctx, key)
if err != nil {
return "", err
}
return row.Value, nil
}
baseURL := config.GetBaseURL(ctx, getter)
if baseURL == "" {
baseURL = cfg.Cfg.BaseURL
}
return baseURL
}
// createJWTMiddleware creates a JWT middleware with proper user context setup
func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
return echojwt.WithConfig(echojwt.Config{
SigningKey: []byte(cfg.Cfg.JWTSecret),
ContextKey: "user",
TokenLookup: "cookie:token,header:Authorization:Bearer ",
SuccessHandler: func(c *echo.Context) error {
token := c.Get("user").(*jwt.Token)
claims := token.Claims.(jwt.MapClaims)
c.Set("user_id", claims["user_id"])
c.Set("user_role", claims["user_role"])
c.Set("user_email", claims["user_email"])
c.Set("user_username", claims["user_username"])
// Parse UUID from string claims
userIDStr, _ := claims["user_id"].(string)
userUUID, err := uuid.Parse(userIDStr)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid user ID in token")
}
c.Set("user", database.Users{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Email: claims["user_email"].(string),
Username: claims["user_username"].(string),
Role: claims["user_role"].(string),
})
return nil
},
ErrorHandler: func(c *echo.Context, err error) error {
// Check if this is a page request (browser navigation)
if wantsHTML(c.Request().Header) {
// Page request → Redirect to login with message
loginURL := "/login?session=expired"
return c.Redirect(http.StatusFound, loginURL)
}
// API request → Return JSON error
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "session_expired",
"message": "Your session has expired. Please log in again.",
})
},
})
}
// ensureUserExistsMiddleware checks if the authenticated user still exists in the database
func ensureUserExistsMiddleware(cfg *Config) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
userIDStr, ok := c.Get("user_id").(string)
if !ok {
return next(c)
}
userUUID, err := uuid.Parse(userIDStr)
if err != nil {
log.Printf("Invalid UUID in user existence check: %v", err)
return next(c)
}
// Check if user exists in database
_, err = cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
if err != nil {
log.Printf("User not found in database: %s", userIDStr)
// Clear invalid cookie
c.SetCookie(&http.Cookie{
Name: "token",
Value: "",
Expires: time.Now().Add(-24 * time.Hour),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
// Redirect to login with session=invalid
return c.Redirect(http.StatusFound, "/login?session=invalid")
}
return next(c)
}
}
}
// wantsHTML determines if the request expects HTML response
func wantsHTML(header http.Header) bool {
// Check Accept header
accept := header.Get("Accept")
if accept != "" && (accept == "text/html" || strings.Contains(accept, "text/html")) {
return true
}
// Check HTMX request
if header.Get("HX-Request") == "true" {
return true
}
// Check for AJAX requests (should get JSON)
if header.Get("X-Requested-With") == "XMLHttpRequest" {
return false
}
// Default to JSON for API routes
return false
}
// RegisterRoutes registers all application routes
func RegisterRoutes(cfg *Config) *handlers.Handler {
e := cfg.Echo
// Set up validator
v := validator.New()
if err := ratelimit.RegisterPasswordValidation(v); err != nil {
log.Fatal("Failed to register password validator:", err)
}
e.Validator = &CustomValidator{validator: v}
// Setup redirect middleware - must run before all routes
e.Pre(setupRedirectMiddleware(cfg))
// 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.Settings.AuthRateLimit(),
CleanupInterval: 5 * time.Minute,
}
rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)
rateLimitMiddleware := ratelimit.RateLimiterMiddleware(rateLimiter)
// Register core application routes (collections, devices, media, etc.) - ONCE
jwtMiddleware := createJWTMiddleware(cfg)
protected := e.Group("/api", jwtMiddleware)
// Create scanner handler for scanner routes and progress routes
scannerHandler := handlers.SetupRoutes(protected, cfg.Queries, cfg.ConnManager, cfg.QueueProcessor, cfg.Cfg)
cfg.ScannerHandler = scannerHandler
// Register route groups
registerSetupRoutes(cfg)
registerAuthRoutes(cfg, rateLimitMiddleware)
registerLibraryRoutes(cfg)
registerDeviceRoutes(cfg)
registerSystemRoutes(cfg)
registerSyncRoutes(cfg)
registerCollectionsRoutes(cfg)
registerSeriesRoutes(cfg)
registerDashboardRoutes(cfg)
registerMediaRoutes(cfg)
registerSearchRoutes(cfg)
registerMatchingRoutes(cfg)
registerConflictRoutes(cfg)
registerAnalyticsRoutes(cfg)
registerQueueRoutes(cfg)
registerJobRoutes(cfg)
registerFiltersRoutes(cfg)
registerOPDSRoutes(cfg)
registerReaderRoutes(cfg)
registerWebSocketRoutes(cfg)
registerFrontendRoutes(cfg)
registerDocumentationRoutes(cfg)
e.Static("/static", "web/static")
// Catch-all 404 handler - must be last
e.GET("/*", func(c *echo.Context) error {
var buf bytes.Buffer
err := templates.ErrorPage("Page not found", "404").Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusNotFound, buf.String())
})
// Start background tasks (queue processor and connection cleanup)
scannerHandler.StartBackgroundTasks()
// Start watch mode for all libraries (after 2 second delay for DB)
go func() {
time.Sleep(2 * time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
setting, err := cfg.Queries.GetSystemSetting(ctx, "auto_scan_enabled")
enabled := true // default
if err == nil && setting != "" {
enabled, _ = strconv.ParseBool(setting)
}
if !enabled {
log.Println("Auto-scan disabled in settings, skipping watch mode startup")
return
}
log.Println("Starting watch mode for all libraries...")
if err := scannerHandler.StartWatchModeForAllLibraries(context.Background()); err != nil {
log.Printf("Failed to start watch mode: %v", err)
} else {
log.Println("Watch mode started successfully")
}
}()
// One-time hash backfill: compute and store SHA-256 for media items
// imported before hashing existed, then flag any content-duplicate groups
// for admin review on the Hash Conflicts page. Runs independently of
// auto-scan (it is a one-shot self-heal, not a recurring scan) and is a
// no-op once every item is hashed. Delayed so it does not compete with
// startup scans for disk I/O.
go func() {
time.Sleep(30 * time.Second)
services.NewHashBackfillService(cfg.Queries).Run(context.Background())
}()
// Register progress routes with actual handler
registerProgressRoutes(cfg, scannerHandler)
// Register scanner routes (admin only)
admin := protected.Group("", handlers.AdminMiddleware)
registerScannerRoutes(admin, scannerHandler)
// Hash conflict routes (admin only)
admin.GET("/api/admin/hash-conflicts", cfg.HashConflictsHandler.ListHashConflicts)
admin.POST("/api/admin/hash-conflicts/:id/resolve", cfg.HashConflictsHandler.ResolveHashConflict)
return scannerHandler
}