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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user