From 1d12a64915d730362f321a770fc2933d5119f88f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 17 Oct 2026 17:30:11 -0400 Subject: [PATCH] =?UTF-8?q?refactor(vis):=20H4.5=20step=201=20=E2=80=94=20?= =?UTF-8?q?LibraryService.VisibleLibrarySet,=20the=20canonical=20resolver;?= =?UTF-8?q?=20media=20endpoints=20migrated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One core for "which libraries can this user see": family-lens default- allow stays where it always was (the GetUserVisibleLibraries SQL); the core adds the shared set+ids shaping that four endpoints each hand-rolled. Media's visibleLibrarySet is now a thin identity adapter (JWT user object -> resolved id) and the download-permission gate uses the set instead of a manual membership loop. Semantic diff (H4.5 step 1, in the plan): zero family-lens divergence found across all six sites — the rule lives in one query; the variation was shape-level (identity adapter, set-vs-ids, UUID idiom). --- internal/handlers/media.go | 27 +++++---------------------- internal/services/library_service.go | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/internal/handlers/media.go b/internal/handlers/media.go index a8812387..a37a04a8 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -654,25 +654,14 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error { // visibleLibrarySet loads the requester's visible libraries — membership // map for param checks, id slice for SQL ANY() filters. +// visibleLibrarySet adapts the media endpoints' identity source (the +// JWT user object) to the canonical resolver (H4.5). func (mh *MediaHandler) visibleLibrarySet(c *echo.Context) (map[string]bool, []pgtype.UUID, error) { user, ok := c.Get("user").(database.Users) if !ok { return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user") } - libraries, err := mh.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID) - if err != nil { - return nil, nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to check library access") - } - set := make(map[string]bool, len(libraries)) - ids := make([]pgtype.UUID, 0, len(libraries)) - for _, lib := range libraries { - if !lib.ID.Valid { - continue - } - set[uuid.UUID(lib.ID.Bytes).String()] = true - ids = append(ids, lib.ID) - } - return set, ids, nil + return mh.libraryService.VisibleLibrarySet(c.Request().Context(), user.ID) } // mediaStorageState reports whether the library storage holding a media @@ -2517,17 +2506,11 @@ func (mh *MediaHandler) ServeFile(c *echo.Context) error { // Library visibility gate: the library is what grants permission to // see and download media. user := c.Get("user").(database.Users) - visibleLibraries, err := mh.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID) + visibleSet, _, err := mh.libraryService.VisibleLibrarySet(c.Request().Context(), user.ID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"}) } - libraryVisible := false - for _, lib := range visibleLibraries { - if lib.ID.Valid && lib.ID.Bytes == libraryUUID.Bytes { - libraryVisible = true - break - } - } + libraryVisible := visibleSet[uuid.UUID(libraryUUID.Bytes).String()] if !libraryVisible { return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"}) } diff --git a/internal/services/library_service.go b/internal/services/library_service.go index b7b91691..1fb93799 100644 --- a/internal/services/library_service.go +++ b/internal/services/library_service.go @@ -4,6 +4,7 @@ import ( "bookhoard/internal/database" "context" "fmt" + "github.com/google/uuid" "log" "os" "path/filepath" @@ -174,6 +175,30 @@ func (s *LibraryService) SetLibraryVisibility(ctx context.Context, userID, libra } // GetUserVisibleLibraries retrieves all libraries visible to a user +// VisibleLibrarySet is THE canonical library-visibility resolver (H4.5): +// every endpoint that asks "which libraries can this user see" funnels +// here. The family-lens rule itself lives in the +// GetUserVisibleLibraries SQL (COALESCE(lv.is_visible, true) — +// default-allow: an admin hides a library, everyone else still sees it). +// Callers adapt their own identity source (JWT user object, device +// token, SSR session); this core takes the resolved user id. +func (s *LibraryService) VisibleLibrarySet(ctx context.Context, userID pgtype.UUID) (map[string]bool, []pgtype.UUID, error) { + rows, err := s.GetUserVisibleLibraries(ctx, userID) + if err != nil { + return nil, nil, fmt.Errorf("failed to check library access: %w", err) + } + set := make(map[string]bool, len(rows)) + ids := make([]pgtype.UUID, 0, len(rows)) + for _, lib := range rows { + if !lib.ID.Valid { + continue + } + set[uuid.UUID(lib.ID.Bytes).String()] = true + ids = append(ids, lib.ID) + } + return set, ids, nil +} + func (s *LibraryService) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]database.GetUserVisibleLibrariesRow, error) { return s.db.GetUserVisibleLibraries(ctx, userID) }