feat(security): library visibility enforced at the source; per-library series; annotation ownership; admin user creation
The family-lens hardening: library_visibility was only enforced where file bytes flowed (download/reader/OPDS) — metadata surfaces leaked hidden libraries to any authenticated account, and registration was open by default with no admin-side user creation. - Series are grouped PER LIBRARY now (same name in two libraries = two entries: manga adaptations / LN splits no longer merge); /api/series aggregates only the requester's VISIBLE libraries and /api/series/books REQUIRES a visible library_id (breaking contract; web + app clients updated in the same change). - /api/media-items, item detail, and the reader content endpoints (chapters/panels/outline/THUMBNAILS — page images leaked unguarded next to a gated GetPage) all enforce the visible set; resolveLibrary no longer honors a hand-typed library_id outside the visible set. - Annotation/note/bookmark mutations (update/delete/tombstone, sync LWW paths incl. conflict resolution) carry ownership predicates: another account's bare row id is a 404, not a silent delete. - Admin user creation: POST /api/auth/users (AdminMiddleware) + a Create User card in the admin Users UI — accounts stay creatable with self-service registration OFF, which is now the schema default (fresh installs; the first-admin bootstrap exception is unchanged). - Tests: series contract re-pinned (per-library, visibility 403s), new ownership + admin-create integration suites, OPDS all=1 test now passes an explicit page size (shared-DB growth made the fresh books fall off page 1), device-cap helper switched to the admin endpoint.
This commit is contained in:
1 parent
97fbdf19fb
commit
431fec05ad
60 files changed
+1573
-691
No files matched your search
+102
-8
@@ -626,6 +626,29 @@ 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.
|
||||
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
|
||||
}
|
||||
|
||||
// ListMediaItems handles GET /api/media-items
|
||||
func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
|
||||
libraryID := c.QueryParam("library_id")
|
||||
@@ -678,6 +701,14 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
|
||||
}
|
||||
|
||||
visibleSet, _, err := mh.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !visibleSet[libraryID] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
items, err := mh.db.ListMediaItemsSorted(c.Request().Context(), database.ListMediaItemsSortedParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Sort: pgtype.Text{String: sort, Valid: true},
|
||||
@@ -691,9 +722,14 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"data": items})
|
||||
}
|
||||
|
||||
_, visibleIDs, err := mh.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := mh.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
VisibleLibraryIds: visibleIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -764,6 +800,15 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Visibility gate: hidden libraries don't leak row detail by id.
|
||||
visibleSet, _, err := mh.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"id": uuid.UUID(item.ID.Bytes).String(),
|
||||
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
|
||||
@@ -1666,6 +1711,12 @@ func (mh *MediaHandler) UpdateMediaNote(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
||||
}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
var req UpdateMediaNoteRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
@@ -1678,6 +1729,7 @@ func (mh *MediaHandler) UpdateMediaNote(c *echo.Context) error {
|
||||
ID: pgtype.UUID{Bytes: noteUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1694,10 +1746,19 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
||||
}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}, pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
} else {
|
||||
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
err = mh.db.DeleteMediaNote(c.Request().Context(), database.DeleteMediaNoteParams{
|
||||
ID: pgtype.UUID{Bytes: noteUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1899,6 +1960,12 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, result.Highlight)
|
||||
}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
highlight, err := mh.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{
|
||||
ID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
|
||||
SelectionText: req.SelectionText,
|
||||
@@ -1906,6 +1973,7 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
|
||||
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
||||
Color: pgtype.Text{String: color, Valid: true},
|
||||
NoteID: noteUUID,
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1924,14 +1992,27 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
|
||||
|
||||
pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil {
|
||||
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgUserID, pgHighlightID, "web"); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID)
|
||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), database.DeleteMediaHighlightParams{
|
||||
ID: pgHighlightID,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -2076,14 +2157,27 @@ func (mh *MediaHandler) DeleteMediaBookmark(c *echo.Context) error {
|
||||
|
||||
pgBookmarkID := pgtype.UUID{Bytes: bookmarkUUID, Valid: true}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgBookmarkID, "web"); err != nil {
|
||||
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgUserID, pgBookmarkID, "web"); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "bookmark not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
if err := mh.db.DeleteMediaBookmark(c.Request().Context(), pgBookmarkID); err != nil {
|
||||
if err := mh.db.DeleteMediaBookmark(c.Request().Context(), database.DeleteMediaBookmarkParams{
|
||||
ID: pgBookmarkID,
|
||||
UserID: pgUserID,
|
||||
}); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user