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

+39 -17
View File
@@ -177,6 +177,7 @@ JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
-- name: ListMediaItemsByLibrary :many
@@ -870,11 +871,11 @@ UPDATE media_notes SET
content = $2,
position = $3,
updated_at = NOW()
WHERE id = $1
WHERE id = $1 AND user_id = $4
RETURNING *;
-- name: DeleteMediaNote :exec
DELETE FROM media_notes WHERE id = $1;
DELETE FROM media_notes WHERE id = $1 AND user_id = $2;
-- Media Highlights queries
-- name: CreateMediaHighlight :one
@@ -896,11 +897,11 @@ UPDATE media_highlights SET
color = $5,
note_id = $6,
updated_at = NOW()
WHERE id = $1
WHERE id = $1 AND user_id = $7
RETURNING *;
-- name: DeleteMediaHighlight :exec
DELETE FROM media_highlights WHERE id = $1;
DELETE FROM media_highlights WHERE id = $1 AND user_id = $2;
-- ============================================
-- ANNOTATION SYNC QUERIES (highlights)
@@ -943,7 +944,7 @@ UPDATE media_highlights SET
updated_at = NOW(),
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
WHERE id = $1 AND user_id = $15
RETURNING *;
-- name: TombstoneMediaHighlightByDedupKey :exec
@@ -958,7 +959,7 @@ UPDATE media_highlights SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
WHERE id = $1 AND user_id = $2;
-- name: PurgeExpiredHighlightTombstones :exec
DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1;
@@ -1000,7 +1001,7 @@ UPDATE media_notes SET
updated_at = NOW(),
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
WHERE id = $1 AND user_id = $13
RETURNING *;
-- name: TombstoneMediaNoteByDedupKey :exec
@@ -1015,7 +1016,7 @@ UPDATE media_notes SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
WHERE id = $1 AND user_id = $2;
-- name: PurgeExpiredNoteTombstones :exec
DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1;
@@ -1058,7 +1059,7 @@ UPDATE media_bookmarks SET
created_at = created_at,
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
WHERE id = $1 AND user_id = $14
RETURNING *;
-- name: TombstoneMediaBookmarkByDedupKey :exec
@@ -1073,7 +1074,7 @@ UPDATE media_bookmarks SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
WHERE id = $1 AND user_id = $2;
-- name: PurgeExpiredBookmarkTombstones :exec
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1;
@@ -2635,30 +2636,51 @@ WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('libra
ORDER BY mi.created_at DESC;
-- name: GetDistinctSeries :many
SELECT series, COUNT(*) as book_count,
-- Series are grouped PER LIBRARY: the same series name in two libraries
-- (novels vs manga adaptations, LN splits) stays two entries. Visible
-- filter enforces per-user library visibility at the source.
SELECT library_id, series, COUNT(*) as book_count,
MAX(series_count) as total_in_series,
MAX(created_at) as last_entry_at
FROM media_items
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series IS NOT NULL AND series != ''
GROUP BY series
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
GROUP BY library_id, series
ORDER BY MAX(created_at) DESC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: GetDistinctSeriesCount :one
SELECT COUNT(DISTINCT series)::int
SELECT COUNT(DISTINCT (library_id, series))::int
FROM media_items
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series IS NOT NULL AND series != '';
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != '';
-- name: GetSeriesCovers :many
SELECT cover_image_path, library_id
FROM media_items
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series = sqlc.narg('series') AND cover_image_path IS NOT NULL AND cover_image_path != ''
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series = sqlc.narg('series') AND cover_image_path IS NOT NULL AND cover_image_path != ''
ORDER BY series_number ASC NULLS LAST
LIMIT sqlc.narg('limit');
-- name: GetSeriesBooks :many
-- Scoped to ONE library: same-name series in different libraries are
-- different stories (manga adaptations, LN splits). Callers must pass a
-- library_id and verify the requester can see that library.
SELECT * FROM media_items
WHERE series = sqlc.narg('series')
AND library_id = sqlc.narg('library_id')
AND archived_at IS NULL
AND missing_scan_count = 0
ORDER BY series_number ASC NULLS LAST;
-- name: GetContinueSeriesItems :many
@@ -2799,7 +2821,7 @@ RETURNING *;
-- name: DeleteMediaBookmark :exec
DELETE FROM media_bookmarks
WHERE id = $1;
WHERE id = $1 AND user_id = $2;
-- name: UpdateMediaBookmark :one
UPDATE media_bookmarks