diff --git a/Makefile b/Makefile
index dcfb0a94..74f04c81 100644
--- a/Makefile
+++ b/Makefile
@@ -58,8 +58,8 @@ test: lint-rendering
# partials; grow one only with a written reason.
lint-rendering:
@violations=""; \
- for f in $$(grep -rlE 'innerHTML[[:space:]]*=[[:space:]]*\`' web/src --include='*.ts' | grep -v 'web/src/reader/'); do \
- count=$$(grep -cE 'innerHTML[[:space:]]*=[[:space:]]*\`' $$f); \
+ for f in $$(grep -rlE 'innerHTML[[:space:]]*=[[:space:]]*' web/src --include='*.ts' | grep -v 'web/src/reader/'); do \
+ count=$$(grep -E 'innerHTML[[:space:]]*=[[:space:]]*' $$f | grep -cvE 'innerHTML[[:space:]]*=[[:space:]]*""[[:space:]]*$$'); \
allowed=$$(grep -F "$$f:" web/src/rendering-allowlist.txt | cut -d: -f2); \
allowed=$${allowed:-0}; \
if [ $$count -gt $$allowed ]; then \
diff --git a/internal/handlers/book_matching.go b/internal/handlers/book_matching.go
index 7aaf1396..538e18df 100644
--- a/internal/handlers/book_matching.go
+++ b/internal/handlers/book_matching.go
@@ -80,6 +80,48 @@ func (h *Handler) LinkBook(c *echo.Context) error {
})
}
+ // Ownership: the device must belong to the caller.
+ user, ok := c.Get("user").(database.Users)
+ if !ok {
+ return c.JSON(http.StatusUnauthorized, map[string]string{
+ "error": "user not authenticated",
+ })
+ }
+ deviceOwned, err := h.db.DeviceExistsForUser(c.Request().Context(), database.DeviceExistsForUserParams{
+ DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
+ UserID: user.ID,
+ })
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, map[string]string{
+ "error": "Failed to verify device",
+ })
+ }
+ if !deviceOwned {
+ return c.JSON(http.StatusForbidden, map[string]string{
+ "error": "Device not found",
+ })
+ }
+
+ // Family-lens: the link target must sit in a library the caller can
+ // see — a hidden library's book can never be linked.
+ visibleSet, visErr := h.visibleLibrarySetFor(c)
+ if visErr != nil {
+ return c.JSON(http.StatusInternalServerError, map[string]string{
+ "error": "Failed to resolve visible libraries",
+ })
+ }
+ item, itemErr := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: req.MediaItemID, Valid: true})
+ if itemErr != nil {
+ return c.JSON(http.StatusNotFound, map[string]string{
+ "error": "Media item not found",
+ })
+ }
+ if !item.LibraryID.Valid || !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
+ return c.JSON(http.StatusForbidden, map[string]string{
+ "error": "Book is not available to you",
+ })
+ }
+
alias, err := matchingService.LinkBook(c.Request().Context(), deviceID, &req)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
@@ -112,6 +154,27 @@ func (h *Handler) GetUnlinkedBooks(c *echo.Context) error {
})
}
+ user, ok := c.Get("user").(database.Users)
+ if !ok {
+ return c.JSON(http.StatusUnauthorized, map[string]string{
+ "error": "user not authenticated",
+ })
+ }
+ deviceOwned, devErr := h.db.DeviceExistsForUser(c.Request().Context(), database.DeviceExistsForUserParams{
+ DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
+ UserID: user.ID,
+ })
+ if devErr != nil {
+ return c.JSON(http.StatusInternalServerError, map[string]string{
+ "error": "Failed to verify device",
+ })
+ }
+ if !deviceOwned {
+ return c.JSON(http.StatusForbidden, map[string]string{
+ "error": "Device not found",
+ })
+ }
+
unlinked, err := matchingService.GetUnlinkedBooks(c.Request().Context(), deviceID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
@@ -509,10 +572,35 @@ func (h *Handler) DeleteDeviceFileAlias(c *echo.Context) error {
})
}
+// visibleLibrarySetFor resolves the requesting user's visible-library set
+// (family-lens) from the request context identity.
+func (h *Handler) visibleLibrarySetFor(c *echo.Context) (map[string]bool, error) {
+ user, ok := c.Get("user").(database.Users)
+ if !ok {
+ return nil, echo.NewHTTPError(http.StatusUnauthorized, "user not authenticated")
+ }
+ set, _, err := services.NewLibraryService(h.db).VisibleLibrarySet(
+ c.Request().Context(), user.ID)
+ return set, err
+}
+
// GetBookMatches handles GET /api/books/match
func (h *Handler) GetBookMatches(c *echo.Context) error {
matchingService := h.getMatchingService()
+ // Family-lens: restrict matches to libraries the requester can see
+ // (absent-from-results — a hidden library's book simply never matches).
+ visibleSet, visErr := h.visibleLibrarySetFor(c)
+ if visErr != nil {
+ return c.JSON(http.StatusInternalServerError, map[string]string{
+ "error": "Failed to resolve visible libraries",
+ })
+ }
+ visibleIDs := make([]string, 0, len(visibleSet))
+ for id := range visibleSet {
+ visibleIDs = append(visibleIDs, id)
+ }
+
// Get query parameters
identifiers := c.QueryParams()["identifier"]
sha256 := c.QueryParam("sha256")
@@ -532,11 +620,12 @@ func (h *Handler) GetBookMatches(c *echo.Context) error {
}
req := &services.BookQueryRequest{
- Identifiers: identifiers,
- SHA256: sha256,
- Title: title,
- Author: author,
- FileSize: fileSize,
+ Identifiers: identifiers,
+ SHA256: sha256,
+ Title: title,
+ Author: author,
+ FileSize: fileSize,
+ VisibleLibraryIDs: visibleIDs,
}
response, err := matchingService.QueryBooks(c.Request().Context(), req)
diff --git a/internal/router/frontend.go b/internal/router/frontend.go
index 8c053640..6049bb24 100644
--- a/internal/router/frontend.go
+++ b/internal/router/frontend.go
@@ -702,10 +702,15 @@ func registerFrontendRoutes(cfg *Config) {
// Books-grid partial: the collection detail page's #books-container
// content. The library switcher swaps this in (htmx.ajax) instead of
// re-rendering the grid client-side from JSON. Family-lens scoped via
- // loadCollectionBooks — same resolution rules as the detail page.
+ // loadCollectionBooksLite — the requested library must be in the
+ // caller's visible set, without the page-only library resolution.
frontendProtected.GET("/collections/:id/books-grid", func(c *echo.Context) error {
- user, err := getTemplateUserWithTheme(c, cfg)
- if err != nil {
+ user, ok := c.Get("user").(database.Users)
+ if !ok {
+ return c.HTML(http.StatusUnauthorized, "
Unauthorized
")
+ }
+ userUUID, uuidErr := uuid.Parse(user.ID.String())
+ if uuidErr != nil {
return c.HTML(http.StatusUnauthorized, "Unauthorized
")
}
collectionID := c.Param("id")
@@ -717,7 +722,10 @@ func registerFrontendRoutes(cfg *Config) {
if err != nil {
return c.HTML(http.StatusNotFound, "Collection not found
")
}
- books, _, isSmart, errCode := cfg.loadCollectionBooks(c, user, collection, collectionID)
+ books, isSmart, errCode := cfg.loadCollectionBooksLite(c, userUUID, collection, collectionID)
+ if errCode == "library_forbidden" {
+ return c.HTML(http.StatusForbidden, "Library not available
")
+ }
if errCode != "" {
return c.HTML(http.StatusBadRequest, "Error loading books
")
}
diff --git a/internal/router/helpers.go b/internal/router/helpers.go
index 8e4e6cfc..90a494a9 100644
--- a/internal/router/helpers.go
+++ b/internal/router/helpers.go
@@ -275,11 +275,69 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
}
}
-// loadCollectionBooks resolves the books shown for a collection: smart
-// evaluation, a dashboard query section, or manual membership — scoped to
-// the requesting user's visible libraries (family-lens) exactly like the
-// collection detail page. Shared by the detail page and the books-grid
-// partial route so the two can never drift.
+// 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)
@@ -297,8 +355,6 @@ func (cfg *Config) loadCollectionBooks(c *echo.Context, user templates.User, col
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
}
}
- colService := cfg.CollectionHandler.CollectionService()
- isSmart = colService.IsSmartCollection(collection.AutoAssignRules)
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
if visErr != nil {
@@ -306,59 +362,46 @@ func (cfg *Config) loadCollectionBooks(c *echo.Context, user templates.User, col
visibleIDs = nil
}
- libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
+ 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
+}
- 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, libRes, isSmart, "books_load_error"
+// 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"
}
- 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, libRes.LibUUID, 1000, []string{}, []string{})
- if secErr != nil {
- return nil, libRes, isSmart, "books_load_error"
+ set, _, setErr := cfg.LibraryService.VisibleLibrarySet(
+ c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
+ if setErr != nil {
+ return nil, isSmart, "books_load_error"
}
-
- for _, section := range sections {
- if section.CollectionID.String() == collectionID {
- books = handlers.BookCardsFromMediaItems(section.Items, libTypes)
- break
- }
+ if !set[libraryID] {
+ return nil, isSmart, "library_forbidden"
}
- } else {
- if libraryID != "" && !libRes.IsAll {
- libUUID, parseErr := uuid.Parse(libraryID)
- if parseErr != nil {
- return nil, libRes, isSmart, "invalid_library_id"
- }
+ libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
+ }
- collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
- database.GetCollectionItemsForDashboardParams{
- CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
- LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
- 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(), pgtype.UUID{Bytes: collUUID, Valid: true})
- if collErr != nil {
- collItems = nil
- }
- books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes)
- }
+ visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
+ if visErr != nil {
+ log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
+ visibleIDs = nil
}
- return books, libRes, isSmart, ""
+
+ return cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID,
+ libUUID, libUUID, libraryID != "", visibleIDs)
}
diff --git a/internal/services/book_matching.go b/internal/services/book_matching.go
index 75f87470..f457f250 100644
--- a/internal/services/book_matching.go
+++ b/internal/services/book_matching.go
@@ -24,7 +24,11 @@ type BookQueryRequest struct {
SHA256 string `json:"sha256"`
Title string `json:"title"`
Author string `json:"author"`
- FileSize int64 `json:"file_size"`
+ // VisibleLibraryIDs scopes results to the requesting user's visible
+ // libraries (family-lens, absent-from-results). Set server-side by the
+ // handler — never from client input. nil keeps legacy callers unfiltered.
+ VisibleLibraryIDs []string `json:"-"`
+ FileSize int64 `json:"file_size"`
}
// BookQueryResponse represents the response to a book query
@@ -106,6 +110,29 @@ func (s *BookMatchingService) QueryBooks(ctx context.Context, req *BookQueryRequ
}
}
+ // Family-lens: matches may only surface items from libraries the
+ // requesting user can see — a hidden library's book simply never
+ // matches (absent-from-results). Runs before determineAction so the
+ // suggested action reflects the filtered set. nil (unset) keeps
+ // legacy callers unfiltered.
+ if req.VisibleLibraryIDs != nil {
+ allowed := make(map[string]bool, len(req.VisibleLibraryIDs))
+ for _, id := range req.VisibleLibraryIDs {
+ allowed[id] = true
+ }
+ filtered := matches[:0]
+ for _, m := range matches {
+ item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: m.MediaItemID, Valid: true})
+ if err != nil || !item.LibraryID.Valid {
+ continue // vanished between cascade and filter, or unscoped — drop
+ }
+ if allowed[uuid.UUID(item.LibraryID.Bytes).String()] {
+ filtered = append(filtered, m)
+ }
+ }
+ matches = filtered
+ }
+
// Determine action
action := s.determineAction(matches)
diff --git a/web/src/dashboard.ts b/web/src/dashboard.ts
index c671f925..49f8aea6 100644
--- a/web/src/dashboard.ts
+++ b/web/src/dashboard.ts
@@ -435,6 +435,34 @@ function initDragAndDrop(): void {
});
}
+// The week widget re-fetches itself when the body sees `week-refresh`
+// (hx-trigger on #week-widget). Resolve once that swap has landed so the
+// library switch fades in with the refreshed widget already in place.
+// Safety timeout keeps a stalled request from holding the switch hostage,
+// and a missing widget (stats disabled) resolves immediately.
+function refreshWeekWidget(): Promise {
+ return new Promise((resolve) => {
+ if (!document.getElementById("week-widget")) {
+ resolve();
+ return;
+ }
+ let settled = false;
+ const finish = (): void => {
+ if (settled) return;
+ settled = true;
+ document.removeEventListener("htmx:afterSwap", onSwap);
+ resolve();
+ };
+ const onSwap = (evt: Event): void => {
+ const target = (evt as CustomEvent).detail?.target as HTMLElement | undefined;
+ if (target && target.id === "week-widget") finish();
+ };
+ document.addEventListener("htmx:afterSwap", onSwap);
+ setTimeout(finish, 4000);
+ document.body.dispatchEvent(new CustomEvent("week-refresh"));
+ });
+}
+
function initDashboard() {
initDragAndDrop();
@@ -444,7 +472,10 @@ function initDashboard() {
await fetchAndRenderSections(libraryId);
// The "This Week" widget tracks the switched library: htmx
// re-fetches its fragment with the current #library-select value.
- document.body.dispatchEvent(new CustomEvent("week-refresh"));
+ // Await the widget's swap so it lands behind the spinner —
+ // otherwise it pops in during/after the fade-in and the switch
+ // reads as two disjoint updates.
+ await refreshWeekWidget();
}),
});
diff --git a/web/src/rendering-allowlist.txt b/web/src/rendering-allowlist.txt
index 5dcac303..1c2d6406 100644
--- a/web/src/rendering-allowlist.txt
+++ b/web/src/rendering-allowlist.txt
@@ -1,15 +1,27 @@
# Rendering-ownership baseline (AGENTS.md → Rendering ownership).
-# Files allowed to build HTML via template literals, with their current
-# site counts. `make lint-rendering` fails when a count grows above its
-# baseline. Shrink counts as fragments move to templ partials — the end
-# state of the triage is an empty list. The reader (web/src/reader/) is a
-# sanctioned island and not counted here.
-web/src/admin.ts:1
-web/src/api-explorer.ts:2
-web/src/collections.ts:1
-web/src/custom-section-builder.ts:2
-web/src/linking.ts:1
-web/src/search.ts:3
-web/src/stats.ts:6
-web/src/toast.ts:1
-web/src/unlinked_books.ts:1
+# Files allowed to build HTML in TypeScript, with their current innerHTML
+# assignment counts (trivial `innerHTML = ""` clears don't count; the
+# reader — web/src/reader/ — is a sanctioned island and not counted).
+# `make lint-rendering` fails when a count grows above its baseline.
+# Shrink counts as fragments move to templ partials — the end state of
+# the triage is an empty list.
+web/src/admin.ts:3
+web/src/api-explorer.ts:4
+web/src/bookshelf.ts:1
+web/src/collection-rules.ts:6
+web/src/collections.ts:5
+web/src/custom-section-builder.ts:10
+web/src/device-management.ts:4
+web/src/docs.ts:5
+web/src/dashboard.ts:2
+web/src/linking.ts:3
+web/src/markdown.ts:1
+web/src/dom.ts:1
+web/src/password_validation.ts:1
+web/src/queue.ts:3
+web/src/search.ts:4
+web/src/series.ts:1
+web/src/setup.ts:1
+web/src/stats.ts:9
+web/src/toast.ts:2
+web/src/unlinked_books.ts:13