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

+139 -48
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.31.0
// source: queries.sql
package database
@@ -2159,20 +2159,30 @@ func (q *Queries) DeleteLibraryFolder(ctx context.Context, arg DeleteLibraryFold
const DeleteMediaBookmark = `-- name: DeleteMediaBookmark :exec
DELETE FROM media_bookmarks
WHERE id = $1
WHERE id = $1 AND user_id = $2
`
func (q *Queries) DeleteMediaBookmark(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, DeleteMediaBookmark, id)
type DeleteMediaBookmarkParams struct {
ID pgtype.UUID `db:"id" json:"id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) DeleteMediaBookmark(ctx context.Context, arg DeleteMediaBookmarkParams) error {
_, err := q.db.Exec(ctx, DeleteMediaBookmark, arg.ID, arg.UserID)
return err
}
const DeleteMediaHighlight = `-- name: DeleteMediaHighlight :exec
DELETE FROM media_highlights WHERE id = $1
DELETE FROM media_highlights WHERE id = $1 AND user_id = $2
`
func (q *Queries) DeleteMediaHighlight(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, DeleteMediaHighlight, id)
type DeleteMediaHighlightParams struct {
ID pgtype.UUID `db:"id" json:"id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) DeleteMediaHighlight(ctx context.Context, arg DeleteMediaHighlightParams) error {
_, err := q.db.Exec(ctx, DeleteMediaHighlight, arg.ID, arg.UserID)
return err
}
@@ -2196,11 +2206,16 @@ func (q *Queries) DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) err
}
const DeleteMediaNote = `-- name: DeleteMediaNote :exec
DELETE FROM media_notes WHERE id = $1
DELETE FROM media_notes WHERE id = $1 AND user_id = $2
`
func (q *Queries) DeleteMediaNote(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, DeleteMediaNote, id)
type DeleteMediaNoteParams struct {
ID pgtype.UUID `db:"id" json:"id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) DeleteMediaNote(ctx context.Context, arg DeleteMediaNoteParams) error {
_, err := q.db.Exec(ctx, DeleteMediaNote, arg.ID, arg.UserID)
return err
}
@@ -3889,31 +3904,45 @@ func (q *Queries) GetDictionaryEntry(ctx context.Context, word string) (Dictiona
}
const GetDistinctSeries = `-- name: GetDistinctSeries :many
SELECT series, COUNT(*) as book_count,
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 ($1::uuid IS NULL OR library_id = $1::uuid) AND series IS NOT NULL AND series != ''
GROUP BY series
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR library_id = ANY($2::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 $3 OFFSET $2
LIMIT $4 OFFSET $3
`
type GetDistinctSeriesParams struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
Offset pgtype.Int4 `db:"offset" json:"offset"`
Limit pgtype.Int4 `db:"limit" json:"limit"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
Offset pgtype.Int4 `db:"offset" json:"offset"`
Limit pgtype.Int4 `db:"limit" json:"limit"`
}
type GetDistinctSeriesRow struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
Series pgtype.Text `db:"series" json:"series"`
BookCount int64 `db:"book_count" json:"book_count"`
TotalInSeries interface{} `db:"total_in_series" json:"total_in_series"`
LastEntryAt interface{} `db:"last_entry_at" json:"last_entry_at"`
}
// 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.
func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) {
rows, err := q.db.Query(ctx, GetDistinctSeries, arg.LibraryID, arg.Offset, arg.Limit)
rows, err := q.db.Query(ctx, GetDistinctSeries,
arg.LibraryID,
arg.VisibleLibraryIds,
arg.Offset,
arg.Limit,
)
if err != nil {
return nil, err
}
@@ -3922,6 +3951,7 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa
for rows.Next() {
var i GetDistinctSeriesRow
if err := rows.Scan(
&i.LibraryID,
&i.Series,
&i.BookCount,
&i.TotalInSeries,
@@ -3938,13 +3968,22 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa
}
const GetDistinctSeriesCount = `-- name: GetDistinctSeriesCount :one
SELECT COUNT(DISTINCT series)::int
SELECT COUNT(DISTINCT (library_id, series))::int
FROM media_items
WHERE ($1::uuid IS NULL OR library_id = $1::uuid) AND series IS NOT NULL AND series != ''
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
`
func (q *Queries) GetDistinctSeriesCount(ctx context.Context, libraryID pgtype.UUID) (int32, error) {
row := q.db.QueryRow(ctx, GetDistinctSeriesCount, libraryID)
type GetDistinctSeriesCountParams struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
}
func (q *Queries) GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error) {
row := q.db.QueryRow(ctx, GetDistinctSeriesCount, arg.LibraryID, arg.VisibleLibraryIds)
var column_1 int32
err := row.Scan(&column_1)
return column_1, err
@@ -7102,11 +7141,22 @@ func (q *Queries) GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams
const GetSeriesBooks = `-- name: GetSeriesBooks :many
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
WHERE series = $1
AND library_id = $2
AND archived_at IS NULL
AND missing_scan_count = 0
ORDER BY series_number ASC NULLS LAST
`
func (q *Queries) GetSeriesBooks(ctx context.Context, series pgtype.Text) ([]MediaItems, error) {
rows, err := q.db.Query(ctx, GetSeriesBooks, series)
type GetSeriesBooksParams struct {
Series pgtype.Text `db:"series" json:"series"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
}
// 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.
func (q *Queries) GetSeriesBooks(ctx context.Context, arg GetSeriesBooksParams) ([]MediaItems, error) {
rows, err := q.db.Query(ctx, GetSeriesBooks, arg.Series, arg.LibraryID)
if err != nil {
return nil, err
}
@@ -7193,15 +7243,20 @@ func (q *Queries) GetSeriesBooks(ctx context.Context, series pgtype.Text) ([]Med
const GetSeriesCovers = `-- name: GetSeriesCovers :many
SELECT cover_image_path, library_id
FROM media_items
WHERE ($1::uuid IS NULL OR library_id = $1::uuid) AND series = $2 AND cover_image_path IS NOT NULL AND cover_image_path != ''
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series = $3 AND cover_image_path IS NOT NULL AND cover_image_path != ''
ORDER BY series_number ASC NULLS LAST
LIMIT $3
LIMIT $4
`
type GetSeriesCoversParams struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
Series pgtype.Text `db:"series" json:"series"`
Limit pgtype.Int4 `db:"limit" json:"limit"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
Series pgtype.Text `db:"series" json:"series"`
Limit pgtype.Int4 `db:"limit" json:"limit"`
}
type GetSeriesCoversRow struct {
@@ -7210,7 +7265,12 @@ type GetSeriesCoversRow struct {
}
func (q *Queries) GetSeriesCovers(ctx context.Context, arg GetSeriesCoversParams) ([]GetSeriesCoversRow, error) {
rows, err := q.db.Query(ctx, GetSeriesCovers, arg.LibraryID, arg.Series, arg.Limit)
rows, err := q.db.Query(ctx, GetSeriesCovers,
arg.LibraryID,
arg.VisibleLibraryIds,
arg.Series,
arg.Limit,
)
if err != nil {
return nil, err
}
@@ -9201,12 +9261,14 @@ 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 ($3::uuid[] IS NULL OR mi.library_id = ANY($3::uuid[]))
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2
`
type ListMediaItemsParams struct {
Limit int32 `db:"limit" json:"limit"`
Offset int32 `db:"offset" json:"offset"`
Limit int32 `db:"limit" json:"limit"`
Offset int32 `db:"offset" json:"offset"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
}
type ListMediaItemsRow struct {
@@ -9279,7 +9341,7 @@ type ListMediaItemsRow struct {
}
func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error) {
rows, err := q.db.Query(ctx, ListMediaItems, arg.Limit, arg.Offset)
rows, err := q.db.Query(ctx, ListMediaItems, arg.Limit, arg.Offset, arg.VisibleLibraryIds)
if err != nil {
return nil, err
}
@@ -12167,11 +12229,16 @@ UPDATE media_bookmarks SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1
WHERE id = $1 AND user_id = $2
`
func (q *Queries) TombstoneMediaBookmarkByID(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, TombstoneMediaBookmarkByID, id)
type TombstoneMediaBookmarkByIDParams struct {
ID pgtype.UUID `db:"id" json:"id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error {
_, err := q.db.Exec(ctx, TombstoneMediaBookmarkByID, arg.ID, arg.UserID)
return err
}
@@ -12199,11 +12266,16 @@ UPDATE media_highlights SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1
WHERE id = $1 AND user_id = $2
`
func (q *Queries) TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, TombstoneMediaHighlightByID, id)
type TombstoneMediaHighlightByIDParams struct {
ID pgtype.UUID `db:"id" json:"id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) TombstoneMediaHighlightByID(ctx context.Context, arg TombstoneMediaHighlightByIDParams) error {
_, err := q.db.Exec(ctx, TombstoneMediaHighlightByID, arg.ID, arg.UserID)
return err
}
@@ -12231,11 +12303,16 @@ UPDATE media_notes SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1
WHERE id = $1 AND user_id = $2
`
func (q *Queries) TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, TombstoneMediaNoteByID, id)
type TombstoneMediaNoteByIDParams struct {
ID pgtype.UUID `db:"id" json:"id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error {
_, err := q.db.Exec(ctx, TombstoneMediaNoteByID, arg.ID, arg.UserID)
return err
}
@@ -12832,7 +12909,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 id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source
`
@@ -12850,6 +12927,7 @@ type UpdateMediaBookmarkForSyncParams struct {
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error) {
@@ -12867,6 +12945,7 @@ func (q *Queries) UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMedi
arg.LastModifiedAt,
arg.LastModifiedSource,
arg.DeviceSyncData,
arg.UserID,
)
var i MediaBookmarks
err := row.Scan(
@@ -12902,7 +12981,7 @@ UPDATE media_highlights SET
color = $5,
note_id = $6,
updated_at = NOW()
WHERE id = $1
WHERE id = $1 AND user_id = $7
RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at
`
@@ -12913,6 +12992,7 @@ type UpdateMediaHighlightParams struct {
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
Color pgtype.Text `db:"color" json:"color"`
NoteID pgtype.UUID `db:"note_id" json:"note_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error) {
@@ -12923,6 +13003,7 @@ func (q *Queries) UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighl
arg.EndPosition,
arg.Color,
arg.NoteID,
arg.UserID,
)
var i MediaHighlights
err := row.Scan(
@@ -12975,7 +13056,7 @@ UPDATE media_highlights SET
updated_at = NOW(),
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
WHERE id = $1 AND user_id = $15
RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at
`
@@ -12994,6 +13075,7 @@ type UpdateMediaHighlightForSyncParams struct {
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error) {
@@ -13012,6 +13094,7 @@ func (q *Queries) UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMed
arg.LastModifiedAt,
arg.LastModifiedSource,
arg.DeviceSyncData,
arg.UserID,
)
var i MediaHighlights
err := row.Scan(
@@ -13617,7 +13700,7 @@ UPDATE media_notes SET
content = $2,
position = $3,
updated_at = NOW()
WHERE id = $1
WHERE id = $1 AND user_id = $4
RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at
`
@@ -13625,10 +13708,16 @@ type UpdateMediaNoteParams struct {
ID pgtype.UUID `db:"id" json:"id"`
Content string `db:"content" json:"content"`
Position pgtype.Text `db:"position" json:"position"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error) {
row := q.db.QueryRow(ctx, UpdateMediaNote, arg.ID, arg.Content, arg.Position)
row := q.db.QueryRow(ctx, UpdateMediaNote,
arg.ID,
arg.Content,
arg.Position,
arg.UserID,
)
var i MediaNotes
err := row.Scan(
&i.ID,
@@ -13670,7 +13759,7 @@ UPDATE media_notes SET
updated_at = NOW(),
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
WHERE id = $1 AND user_id = $13
RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at
`
@@ -13687,6 +13776,7 @@ type UpdateMediaNoteForSyncParams struct {
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error) {
@@ -13703,6 +13793,7 @@ func (q *Queries) UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNot
arg.LastModifiedAt,
arg.LastModifiedSource,
arg.DeviceSyncData,
arg.UserID,
)
var i MediaNotes
err := row.Scan(