Release / build-and-push (push) Successful in 2m48s
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.
322 lines
10 KiB
Go
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
|
|
}
|