package router import ( "context" "log" "net/http" "net/url" "os" "sync" "time" "bookhoard/internal/database" "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: getText(userDB.FirstName), LastName: getText(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 getText(t pgtype.Text) string { if t.Valid { return t.String } return "" } 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: getText(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") } libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userU)) if err != nil { return nil, nil, err } set := make(map[string]bool, len(libraries)) ids := make([]pgtype.UUID, 0, len(libraries)) for _, lib := range libraries { if !lib.ID.Valid { continue } set[lib.ID.String()] = true ids = append(ids, lib.ID) } return set, ids, nil }