feat(matching): family-lens scoping for book matching and linking
/api/books/match returned media metadata from ALL libraries regardless
of visibility: the resolver lookups (GetMediaItemBySHA256 and friends)
are global, so match results leaked hidden-library titles, covers and
ids to any authenticated user, and LinkBook accepted a link target from
a hidden library. Both now enforce the family-lens rule:
- QueryBooks post-filters the cascade by the requester's visible-library
set (absent-from-results — a hidden library's book simply never
matches), before determineAction so the suggested action reflects the
filtered set. BookQueryRequest gains a server-side-only
VisibleLibraryIDs field; nil keeps legacy callers unfiltered.
- LinkBook verifies the device belongs to the caller
(DeviceExistsForUser) and the target item's library is visible —
403 otherwise. Linking was previously unscoped on both axes.
- GetUnlinkedBooks likewise verifies device ownership (its service is
still a stub, but the route no longer answers for other users'
devices).
Also: the collections books-grid partial uses a light loader —
requested library validated against the visible set directly instead of
the page-only library resolution (folder reachability, counts,
dropdown payload), removing the extra queries that made library
switching hold the spinner longer.
And the dashboard library switch now folds the This Week widget's
htmx refresh behind the spinner (refreshWeekWidget awaits the widget's
swap, with a safety timeout) instead of letting it pop in after the
fade-in — the two-stage update introduced with the widget's
library-scoped re-fetch.
Guardrail: the rendering lint now counts all innerHTML assignments (not
just template-literal ones) — the old pattern undercounted 20 files at
80 sites, including .join("") builders like renderDashboardCollections.
Baseline rebalanced accordingly.
This commit is contained in:
1 parent
4a68fe7cab
commit
040c10ac76
7 files changed
+293
-83
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user