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