package router import ( "context" "log" "net/http" "net/url" "os" "sync" "time" "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/internal/services" "bookhoard/internal/utils" "bookhoard/templates" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v5" ) func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, error) { userID := c.Get("user_id").(string) userEmail := c.Get("user_email").(string) userUsername := c.Get("user_username").(string) userRole := c.Get("user_role").(string) userUUID, err := uuid.Parse(userID) if err != nil { log.Printf("getTemplateUserWithTheme failed: invalid UUID '%s': %v", userID, err) return templates.User{}, err } userDB, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID)) if err != nil { log.Printf("getTemplateUserWithTheme failed: database query error for user ID %s: %v", userID, err) return templates.User{}, err } userTheme := "tokyo-night" if userDB.Theme.Valid { userTheme = userDB.Theme.String } userTimezone := "UTC" if userDB.Timezone.Valid { userTimezone = userDB.Timezone.String } // Extract JWT token for WebSocket authentication token := "" if cookie, err := c.Cookie("token"); err == nil { token = cookie.Value } return templates.User{ ID: userID, Email: userEmail, Username: userUsername, Role: userRole, Theme: userTheme, Token: token, Timezone: userTimezone, FirstName: utils.PgTextString(userDB.FirstName), LastName: utils.PgTextString(userDB.LastName), CreatedAt: userDB.CreatedAt.Time, }, nil } func convertPending(pending []map[string]interface{}) []templates.PendingRegistrationData { result := make([]templates.PendingRegistrationData, len(pending)) for i, p := range pending { result[i] = templates.PendingRegistrationData{ RegistrationID: p["registration_id"].(string), DeviceName: p["device_name"].(string), DeviceType: p["device_type"].(string), ExpiresAt: p["expires_at"].(time.Time).Format(time.RFC3339), } } return result } func pingDB(cfg *Config, ctx context.Context) error { if cfg.DBPool != nil { if pool, ok := cfg.DBPool.(*pgxpool.Pool); ok { return pool.Ping(ctx) } } return nil } func parseUUID(s string) (uuid.UUID, error) { return uuid.Parse(s) } func uuidToPGType(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: u, Valid: true} } const selectedLibraryCookie = "selectedLibrary" const allLibrariesSentinel = "__all__" type LibraryResolution struct { LibraryID string IsAll bool LibUUID pgtype.UUID Libraries []templates.LibraryData FirstID string } // folderStatEntry caches one folder-reachability result. type folderStatEntry struct { exists bool at time.Time } const folderStatTTL = 30 * time.Second var folderStatCache sync.Map // folderReachableCached stats a library folder with a short TTL cache: // resolveLibrary runs on every SSR render, and a stat per library folder // over a network mount adds real latency to each page. The cache trades // ~30s of stale online/offline state for snappy renders. func folderReachableCached(path string) bool { if v, ok := folderStatCache.Load(path); ok { if e, ok := v.(folderStatEntry); ok && time.Since(e.at) < folderStatTTL { return e.exists } } exists := false if _, err := os.Stat(path); err == nil { exists = true } folderStatCache.Store(path, folderStatEntry{exists: exists, at: time.Now()}) return exists } func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolution { res := LibraryResolution{} userU, _ := uuid.Parse(userUUID) libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userU)) if err != nil { log.Printf("GetUserVisibleLibraries failed: %v", err) libraries = []database.GetUserVisibleLibrariesRow{} } visibleSet := make(map[string]bool, len(libraries)) for _, lib := range libraries { if !lib.ID.Valid { continue } if id, err := uuid.FromBytes(lib.ID.Bytes[0:16]); err == nil { visibleSet[id.String()] = true } } counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU)) if countErr != nil { log.Printf("GetVisibleLibraryMediaCounts failed: %v", countErr) counts = []database.GetVisibleLibraryMediaCountsRow{} } countMap := make(map[string]int64, len(counts)) for _, mc := range counts { mcUUID, _ := uuid.FromBytes(mc.ID.Bytes[0:16]) countMap[mcUUID.String()] = mc.MediaCount } res.Libraries = make([]templates.LibraryData, len(libraries)) for i, lib := range libraries { libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) // Steam Deck SD-card behavior: a library whose folders are all // missing (unmounted drive) renders offline - hidden contents, no // marking, no purging - and returns when the storage does. offline := false folders, folderErr := cfg.Queries.GetLibraryFolders(c.Request().Context(), lib.ID) if folderErr != nil || len(folders) == 0 { offline = true } else { anyFolderExists := false for _, folder := range folders { if folderReachableCached(folder.FolderPath) { anyFolderExists = true break } } offline = !anyFolderExists } res.Libraries[i] = templates.LibraryData{ ID: libUUID.String(), Name: lib.Name, Description: utils.PgTextString(lib.Description), TypeName: lib.TypeName, TypeValue: lib.TypeName, MediaCount: countMap[libUUID.String()], Offline: offline, } } if len(libraries) > 0 { libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16]) res.FirstID = libUUID.String() } libraryID := c.QueryParam("library_id") if libraryID == "" { if cookie, err := c.Cookie(selectedLibraryCookie); err == nil { val, _ := url.QueryUnescape(cookie.Value) if val == allLibrariesSentinel { res.IsAll = true res.LibraryID = "" return res } if _, parseErr := uuid.Parse(val); parseErr == nil { libraryID = val } } } if libraryID == "" { res.LibraryID = res.FirstID if res.LibraryID != "" { parsed, _ := uuid.Parse(res.LibraryID) res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true} } return res } // A requested library outside the requester's visible set is not // honored — fall back to the first visible library instead of leaking // hidden-library contents through a hand-typed query param or cookie. if !visibleSet[libraryID] { res.LibraryID = res.FirstID if res.LibraryID != "" { parsed, _ := uuid.Parse(res.LibraryID) res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true} } return res } res.LibraryID = libraryID parsed, _ := uuid.Parse(libraryID) res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true} return res } // visibleLibraryIDsFor returns the requester's visible-library id set (for // membership checks) plus the ids themselves (for SQL ANY() filters). func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgtype.UUID, error) { userUUIDStr, ok := c.Get("user_id").(string) if !ok { return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user") } userU, err := uuid.Parse(userUUIDStr) if err != nil { return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user") } return cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), uuidToPGType(userU)) } // collectionDataFromRow builds the template collection payload shared by // the collection detail page and its books-grid partial. func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData { return templates.CollectionData{ ID: collectionID, Name: collection.Name, Description: collection.Description.String, Color: collection.Color.String, Icon: collection.Icon.String, IsSystem: collection.IsSystemCollection.Bool, IsSmart: isSmart, BookCount: bookCount, } } // collectionBooksForScope runs the per-scope book loading shared by the // detail page and the books-grid partial: smart evaluation, a dashboard // query section, or manual membership. Callers resolve the scope: // libUUID/libScope/isAll/scoped per their own resolution rules. func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) { colService := cfg.CollectionHandler.CollectionService() isSmart = colService.IsSmartCollection(collection.AutoAssignRules) libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries) if isSmart { // Smart (dynamic) collection: membership is evaluated live — // visible libraries, live items only, any enabled rule matches. matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(), colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{ LibraryID: libScope, VisibleIDs: visibleIDs, }) if evalErr != nil { return nil, isSmart, "books_load_error" } books = handlers.BookCardsFromMediaItems(matches, libTypes) } else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" { dashboardSvc := services.NewDashboardService(cfg.Queries) sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{}) if secErr != nil { return nil, isSmart, "books_load_error" } for _, section := range sections { if section.CollectionID.String() == collectionID { books = handlers.BookCardsFromMediaItems(section.Items, libTypes) break } } } else { if scoped && libUUID.Valid { collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(), database.GetCollectionItemsForDashboardParams{ CollectionID: collUUID, LibraryID: libUUID, Limit: pgtype.Int4{Int32: 1000, Valid: true}, }) if collErr != nil { books = handlers.BookCardsFromCollectionDashRows(nil, libTypes) } else { // The query filters excluded rows in SQL. books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes) } } else { collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), collUUID) if collErr != nil { collItems = nil } books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes) } } return books, isSmart, "" } // loadCollectionBooks resolves the books shown for a collection detail // page: full library resolution (visible-library payload, folder // reachability, counts) because the page render needs it. The books-grid // partial uses the lighter loadCollectionBooksLite instead. // errCode is "" on success, otherwise a renderErrorPage code. func (cfg *Config) loadCollectionBooks(c *echo.Context, user templates.User, collection database.Collections, collectionID string) (books []handlers.BookInfo, libRes LibraryResolution, isSmart bool, errCode string) { collUUID, parseErr := uuid.Parse(collectionID) if parseErr != nil { return nil, libRes, isSmart, "invalid_id" } userUUID, _ := uuid.Parse(user.ID) libRes = resolveLibrary(c, cfg, user.ID) libraryID := libRes.LibraryID var libScope pgtype.UUID if !libRes.IsAll { if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil { libScope = pgtype.UUID{Bytes: parsed, Valid: true} } } visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c) if visErr != nil { log.Printf("UserVisibleLibraryIDs failed: %v", visErr) visibleIDs = nil } scoped := libraryID != "" && !libRes.IsAll books, isSmart, errCode = cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID, libRes.LibUUID, libScope, scoped, visibleIDs) return books, libRes, isSmart, errCode } // loadCollectionBooksLite is the books-grid partial's loader. The switcher // refresh only needs "is the requested library visible, and its books" — // so it validates the requested library against the caller's visible set // directly (family-lens: out-of-set is rejected) and skips the page-only // library resolution (folder reachability, counts, dropdown payload). func (cfg *Config) loadCollectionBooksLite(c *echo.Context, userUUID uuid.UUID, collection database.Collections, collectionID string) (books []handlers.BookInfo, isSmart bool, errCode string) { collUUID, parseErr := uuid.Parse(collectionID) if parseErr != nil { return nil, isSmart, "invalid_id" } libraryID := c.QueryParam("library_id") var libUUID pgtype.UUID if libraryID != "" { parsed, parseErr := uuid.Parse(libraryID) if parseErr != nil { return nil, isSmart, "invalid_library_id" } set, _, setErr := cfg.LibraryService.VisibleLibrarySet( c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}) if setErr != nil { return nil, isSmart, "books_load_error" } if !set[libraryID] { return nil, isSmart, "library_forbidden" } libUUID = pgtype.UUID{Bytes: parsed, Valid: true} } visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c) if visErr != nil { log.Printf("UserVisibleLibraryIDs failed: %v", visErr) visibleIDs = nil } return cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID, libUUID, libUUID, libraryID != "", visibleIDs) } // libraryTypeOptionsFor loads the library_types rows for the create-library // dropdowns (admin page + setup wizard), ordered for display. On DB failure // it falls back to the built-in type names so the form stays usable — // CreateLibrary resolves the name against the DB, so this is never // authoritative. func libraryTypeOptionsFor(ctx context.Context, cfg *Config) []database.LibraryTypes { types, err := cfg.LibraryService.GetLibraryTypes(ctx) if err != nil { log.Printf("libraryTypeOptionsFor: GetLibraryTypes failed, using built-in type names: %v", err) for _, name := range []string{ services.LibraryTypeEbooks, services.LibraryTypeComics, services.LibraryTypeManga, services.LibraryTypeAudiobooks, } { types = append(types, database.LibraryTypes{Name: name}) } } return templates.OrderedLibraryTypes(types) }