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

+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.31.0
package database
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.31.0
package database
+15 -9
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.31.0
package database
@@ -131,12 +131,12 @@ type Querier interface {
DeleteKoboEntitlement(ctx context.Context, arg DeleteKoboEntitlementParams) error
DeleteLibrary(ctx context.Context, id pgtype.UUID) error
DeleteLibraryFolder(ctx context.Context, arg DeleteLibraryFolderParams) (LibraryFolders, error)
DeleteMediaBookmark(ctx context.Context, id pgtype.UUID) error
DeleteMediaHighlight(ctx context.Context, id pgtype.UUID) error
DeleteMediaBookmark(ctx context.Context, arg DeleteMediaBookmarkParams) error
DeleteMediaHighlight(ctx context.Context, arg DeleteMediaHighlightParams) error
DeleteMediaItem(ctx context.Context, id pgtype.UUID) error
// Delete media item format
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
DeleteMediaNote(ctx context.Context, id pgtype.UUID) error
DeleteMediaNote(ctx context.Context, arg DeleteMediaNoteParams) error
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
@@ -205,8 +205,11 @@ type Querier interface {
// Get device shelf mappings
GetDeviceShelfMappings(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceShelfMappingsRow, error)
GetDictionaryEntry(ctx context.Context, word string) (DictionaryCache, error)
// 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.
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
GetDistinctSeriesCount(ctx context.Context, libraryID pgtype.UUID) (int32, error)
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error)
@@ -306,7 +309,10 @@ type Querier interface {
GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error)
GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error)
GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error)
GetSeriesBooks(ctx context.Context, series pgtype.Text) ([]MediaItems, error)
// 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.
GetSeriesBooks(ctx context.Context, arg GetSeriesBooksParams) ([]MediaItems, error)
GetSeriesCovers(ctx context.Context, arg GetSeriesCoversParams) ([]GetSeriesCoversRow, error)
GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, error)
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
@@ -460,11 +466,11 @@ type Querier interface {
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
TombstoneMediaBookmarkByID(ctx context.Context, id pgtype.UUID) error
TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error
TombstoneMediaHighlightByDedupKey(ctx context.Context, arg TombstoneMediaHighlightByDedupKeyParams) error
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
TombstoneMediaHighlightByID(ctx context.Context, arg TombstoneMediaHighlightByIDParams) error
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
// Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
+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(
+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