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:
John O'Keefe committed 2026-10-13 12:03:01 -04:00
1 parent 97fbdf19fb
commit 431fec05ad
60 files changed
+1573 -691

No files matched your search

+26 -6
View File
@@ -258,6 +258,7 @@ func (s *AnnotationService) applyLWW(
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
UserID: req.UserID,
})
if err != nil {
return nil, fmt.Errorf("update highlight: %w", err)
@@ -328,14 +329,22 @@ func (s *AnnotationService) TombstoneHighlight(
func (s *AnnotationService) TombstoneHighlightByID(
ctx context.Context,
highlightID pgtype.UUID,
userID, highlightID pgtype.UUID,
source string,
) error {
h, err := s.db.GetMediaHighlight(ctx, highlightID)
if err != nil {
return fmt.Errorf("get highlight for tombstone: %w", err)
}
err = s.db.TombstoneMediaHighlightByID(ctx, highlightID)
// Ownership predicate: the caller must own the row. A mismatched user
// gets a not-found-style error, never the row's existence.
if h.UserID != userID {
return fmt.Errorf("tombstone highlight by ID: %w", pgx.ErrNoRows)
}
err = s.db.TombstoneMediaHighlightByID(ctx, database.TombstoneMediaHighlightByIDParams{
ID: highlightID,
UserID: userID,
})
if err != nil {
return fmt.Errorf("tombstone highlight by ID: %w", err)
}
@@ -646,8 +655,11 @@ func (s *AnnotationService) compareIncomingNote(req SaveNoteRequest, existing da
return req.ModifiedAt.After(existingMod.Time), true
}
func (s *AnnotationService) TombstoneNoteByID(ctx context.Context, id pgtype.UUID) error {
return s.db.TombstoneMediaNoteByID(ctx, id)
func (s *AnnotationService) TombstoneNoteByID(ctx context.Context, id, userID pgtype.UUID) error {
return s.db.TombstoneMediaNoteByID(ctx, database.TombstoneMediaNoteByIDParams{
ID: id,
UserID: userID,
})
}
type SaveBookmarkRequest struct {
@@ -788,6 +800,7 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
UserID: req.UserID,
})
if err != nil {
return nil, fmt.Errorf("update bookmark: %w", err)
@@ -817,12 +830,19 @@ func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, exi
return req.ModifiedAt.After(existingMod.Time), true
}
func (s *AnnotationService) TombstoneBookmarkByID(ctx context.Context, bookmarkID pgtype.UUID, source string) error {
func (s *AnnotationService) TombstoneBookmarkByID(ctx context.Context, userID, bookmarkID pgtype.UUID, source string) error {
bm, err := s.db.GetMediaBookmark(ctx, bookmarkID)
if err != nil {
return fmt.Errorf("get bookmark for tombstone: %w", err)
}
err = s.db.TombstoneMediaBookmarkByID(ctx, bookmarkID)
// Ownership predicate: the caller must own the row.
if bm.UserID != userID {
return fmt.Errorf("tombstone bookmark by ID: %w", pgx.ErrNoRows)
}
err = s.db.TombstoneMediaBookmarkByID(ctx, database.TombstoneMediaBookmarkByIDParams{
ID: bookmarkID,
UserID: userID,
})
if err != nil {
return fmt.Errorf("tombstone bookmark by ID: %w", err)
}