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
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.31.0
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.31.0
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -686,6 +687,79 @@ func (h *AuthHandler) ListUsers(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
// AdminCreateUser handles POST /api/auth/users (AdminMiddleware-gated).
|
||||
// The admin's counterpart to public registration: creating an account for
|
||||
// someone else must work even when self-service registration is disabled
|
||||
// (the family/self-hosted default). The requester's admin role comes from
|
||||
// the route group, and the new account NEVER auto-logins — no tokens are
|
||||
// issued here, unlike Register.
|
||||
func (h *AuthHandler) AdminCreateUser(c *echo.Context) error {
|
||||
var req RegisterRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if req.Username == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "username cannot be empty or whitespace"})
|
||||
}
|
||||
|
||||
role := strings.ToLower(req.Role)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if role != "user" && role != "admin" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'user' or 'admin'"})
|
||||
}
|
||||
|
||||
if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "email already exists"})
|
||||
}
|
||||
if _, err := h.db.GetUserByUsername(c.Request().Context(), req.Username); err == nil {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"})
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
||||
}
|
||||
|
||||
user, err := h.db.CreateUser(c.Request().Context(), database.CreateUserParams{
|
||||
Email: req.Email,
|
||||
Username: req.Username,
|
||||
PasswordHash: string(hashedPassword),
|
||||
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
|
||||
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
|
||||
Theme: pgtype.Text{String: "tokyo-night", Valid: true},
|
||||
Role: role,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create default collections"})
|
||||
}
|
||||
|
||||
// The admin UI posts this via HTMX: refresh the page so the new user
|
||||
// appears in the table, and confirm inline.
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
c.Response().Header().Set("HX-Refresh", "true")
|
||||
return c.HTML(http.StatusCreated, `<div class="text-green-500">User "`+template.HTMLEscapeString(user.Username)+`" created.</div>`)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||
"id": uuid.UUID(user.ID.Bytes).String(),
|
||||
"email": user.Email,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
"created_at": user.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// normalizePath cleans and normalizes folder paths for consistent storage and comparison
|
||||
func normalizePath(path string) string {
|
||||
fmt.Printf("normalizePath input: '%s'\n", path)
|
||||
|
||||
@@ -406,6 +406,7 @@ func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaIte
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if v, ok := data["selection_text"].(string); ok {
|
||||
@@ -451,6 +452,7 @@ func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItem
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if v, ok := data["title"].(string); ok {
|
||||
@@ -486,6 +488,7 @@ func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID p
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if v, ok := data["content"].(string); ok {
|
||||
|
||||
+102
-8
@@ -626,6 +626,29 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// visibleLibrarySet loads the requester's visible libraries — membership
|
||||
// map for param checks, id slice for SQL ANY() filters.
|
||||
func (mh *MediaHandler) visibleLibrarySet(c *echo.Context) (map[string]bool, []pgtype.UUID, error) {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
libraries, err := mh.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID)
|
||||
if err != nil {
|
||||
return nil, nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to check library access")
|
||||
}
|
||||
set := make(map[string]bool, len(libraries))
|
||||
ids := make([]pgtype.UUID, 0, len(libraries))
|
||||
for _, lib := range libraries {
|
||||
if !lib.ID.Valid {
|
||||
continue
|
||||
}
|
||||
set[uuid.UUID(lib.ID.Bytes).String()] = true
|
||||
ids = append(ids, lib.ID)
|
||||
}
|
||||
return set, ids, nil
|
||||
}
|
||||
|
||||
// ListMediaItems handles GET /api/media-items
|
||||
func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
|
||||
libraryID := c.QueryParam("library_id")
|
||||
@@ -678,6 +701,14 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
|
||||
}
|
||||
|
||||
visibleSet, _, err := mh.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !visibleSet[libraryID] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
items, err := mh.db.ListMediaItemsSorted(c.Request().Context(), database.ListMediaItemsSortedParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Sort: pgtype.Text{String: sort, Valid: true},
|
||||
@@ -691,9 +722,14 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"data": items})
|
||||
}
|
||||
|
||||
_, visibleIDs, err := mh.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := mh.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
VisibleLibraryIds: visibleIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -764,6 +800,15 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Visibility gate: hidden libraries don't leak row detail by id.
|
||||
visibleSet, _, err := mh.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"id": uuid.UUID(item.ID.Bytes).String(),
|
||||
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
|
||||
@@ -1666,6 +1711,12 @@ func (mh *MediaHandler) UpdateMediaNote(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
||||
}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
var req UpdateMediaNoteRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
@@ -1678,6 +1729,7 @@ func (mh *MediaHandler) UpdateMediaNote(c *echo.Context) error {
|
||||
ID: pgtype.UUID{Bytes: noteUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1694,10 +1746,19 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
||||
}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}, pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
} else {
|
||||
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
err = mh.db.DeleteMediaNote(c.Request().Context(), database.DeleteMediaNoteParams{
|
||||
ID: pgtype.UUID{Bytes: noteUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1899,6 +1960,12 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, result.Highlight)
|
||||
}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
highlight, err := mh.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{
|
||||
ID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
|
||||
SelectionText: req.SelectionText,
|
||||
@@ -1906,6 +1973,7 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
|
||||
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
||||
Color: pgtype.Text{String: color, Valid: true},
|
||||
NoteID: noteUUID,
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1924,14 +1992,27 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
|
||||
|
||||
pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil {
|
||||
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgUserID, pgHighlightID, "web"); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID)
|
||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), database.DeleteMediaHighlightParams{
|
||||
ID: pgHighlightID,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -2076,14 +2157,27 @@ func (mh *MediaHandler) DeleteMediaBookmark(c *echo.Context) error {
|
||||
|
||||
pgBookmarkID := pgtype.UUID{Bytes: bookmarkUUID, Valid: true}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgBookmarkID, "web"); err != nil {
|
||||
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgUserID, pgBookmarkID, "web"); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "bookmark not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
if err := mh.db.DeleteMediaBookmark(c.Request().Context(), pgBookmarkID); err != nil {
|
||||
if err := mh.db.DeleteMediaBookmark(c.Request().Context(), database.DeleteMediaBookmarkParams{
|
||||
ID: pgBookmarkID,
|
||||
UserID: pgUserID,
|
||||
}); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,27 @@ func (h *ReaderHandler) GetPage(c *echo.Context) error {
|
||||
}
|
||||
|
||||
// GetChapters returns chapter metadata
|
||||
// ensureItemVisible enforces the library-visibility gate for reader
|
||||
// content and layout endpoints (same rule GetPage applies): a hidden
|
||||
// library leaks nothing by item id.
|
||||
func (h *ReaderHandler) ensureItemVisible(c *echo.Context, mediaItem *database.MediaItems) error {
|
||||
user := c.Get("user")
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "User not authenticated"})
|
||||
}
|
||||
userData := user.(database.Users)
|
||||
libraries, err := h.libraryService.GetUserVisibleLibraries(c.Request().Context(), userData.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to check library access"})
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
if lib.ID == mediaItem.LibraryID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied to this library"})
|
||||
}
|
||||
|
||||
func (h *ReaderHandler) GetChapters(c *echo.Context) error {
|
||||
mediaItemID := c.Param("mediaItemId")
|
||||
parsedUUID, err := uuid.Parse(mediaItemID)
|
||||
@@ -220,6 +241,14 @@ func (h *ReaderHandler) GetChapters(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
|
||||
}
|
||||
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
|
||||
}
|
||||
if err := h.ensureItemVisible(c, &mediaItem); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use reader service to detect chapters
|
||||
chapters, err := h.readerService.DetectChapters(c.Request().Context(), parsedUUID)
|
||||
if err != nil {
|
||||
@@ -246,6 +275,14 @@ func (h *ReaderHandler) GetPanels(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
|
||||
}
|
||||
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
|
||||
}
|
||||
if err := h.ensureItemVisible(c, &mediaItem); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get detection method from query
|
||||
method := c.QueryParam("method")
|
||||
if method == "" {
|
||||
@@ -398,6 +435,9 @@ func (h *ReaderHandler) GetPDFOutline(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
|
||||
}
|
||||
if err := h.ensureItemVisible(c, &mediaItem); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only PDFs have outlines
|
||||
if mediaItem.FormatGroup != "pdf" {
|
||||
@@ -438,6 +478,9 @@ func (h *ReaderHandler) GetPDFThumbnail(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
|
||||
}
|
||||
if err := h.ensureItemVisible(c, &mediaItem); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get thumbnail size from query
|
||||
width := c.QueryParam("width")
|
||||
|
||||
@@ -50,13 +50,25 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, limit, offset)
|
||||
// Visibility scoping: an explicit library_id must be visible to the
|
||||
// requester (403 otherwise); the unscoped aggregate covers only the
|
||||
// requester's visible libraries.
|
||||
visibleSet, visibleIDs, err := h.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if libraryID != "" && !visibleSet[libraryID] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
|
||||
}
|
||||
|
||||
type SeriesResponse struct {
|
||||
Name string `json:"name"`
|
||||
LibraryID string `json:"library_id"`
|
||||
BookCount int64 `json:"book_count"`
|
||||
TotalInSeries int `json:"total_in_series"`
|
||||
CoverPaths []string `json:"cover_paths"`
|
||||
@@ -67,6 +79,7 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
for _, s := range seriesList {
|
||||
response = append(response, SeriesResponse{
|
||||
Name: s.Name,
|
||||
LibraryID: s.LibraryID,
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: s.CoverPaths,
|
||||
@@ -82,13 +95,58 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// visibleLibrarySet loads the requester's visible libraries. Every series
|
||||
// path funnels through this: hidden libraries contribute nothing, and an
|
||||
// explicit library_id outside the set is a 403, not a silent pass.
|
||||
func (h *SeriesHandler) visibleLibrarySet(c *echo.Context) (map[string]bool, []pgtype.UUID, error) {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
libraries, err := h.seriesService.VisibleLibraries(c.Request().Context(), user.ID)
|
||||
if err != nil {
|
||||
return nil, nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to check library access")
|
||||
}
|
||||
set := make(map[string]bool, len(libraries))
|
||||
ids := make([]pgtype.UUID, 0, len(libraries))
|
||||
for _, lib := range libraries {
|
||||
if !lib.ID.Valid {
|
||||
continue
|
||||
}
|
||||
libUUID, err := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
set[libUUID.String()] = true
|
||||
ids = append(ids, lib.ID)
|
||||
}
|
||||
return set, ids, nil
|
||||
}
|
||||
|
||||
func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
||||
seriesName := c.QueryParam("name")
|
||||
if seriesName == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"})
|
||||
}
|
||||
|
||||
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), seriesName)
|
||||
libraryID := c.QueryParam("library_id")
|
||||
if libraryID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
||||
}
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
|
||||
visibleSet, _, err := h.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !visibleSet[libraryID] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true}, seriesName)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series books"})
|
||||
}
|
||||
@@ -105,13 +163,14 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"name": seriesName,
|
||||
"books": bookCards,
|
||||
"total": len(bookCards),
|
||||
"name": seriesName,
|
||||
"library_id": libraryID,
|
||||
"books": bookCards,
|
||||
"total": len(bookCards),
|
||||
})
|
||||
}
|
||||
|
||||
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
|
||||
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
|
||||
svc := services.NewSeriesService(db)
|
||||
return svc.GetSeriesPage(ctx, libraryID, limit, offset)
|
||||
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset)
|
||||
}
|
||||
@@ -40,6 +40,7 @@ func registerAuthRoutes(cfg *Config, rateLimitMiddleware echo.MiddlewareFunc) {
|
||||
// Admin-only routes (same handlers with URL param)
|
||||
admin := protected.Group("/auth", handlers.AdminMiddleware)
|
||||
admin.GET("/users", cfg.AuthHandler.ListUsers)
|
||||
admin.POST("/users", cfg.AuthHandler.AdminCreateUser)
|
||||
admin.PUT("/users/:id/max-devices", cfg.AuthHandler.UpdateUserMaxDevices)
|
||||
admin.PUT("/profile/:id", cfg.AuthHandler.UpdateProfile)
|
||||
admin.PUT("/password/:id", cfg.AuthHandler.UpdatePassword)
|
||||
|
||||
+48
-19
@@ -151,27 +151,38 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
totalPages := 1
|
||||
|
||||
if errorMsg == "" {
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, perSeriesPage, offset)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesCardsData failed: %v", err)
|
||||
// Visible-library scoping: cards are grouped per library, and a
|
||||
// specific library selection only renders if it's in the set.
|
||||
visibleSet, visibleIDs, visErr := visibleLibraryIDsFor(c, cfg)
|
||||
if visErr != nil {
|
||||
log.Printf("visibleLibraryIDsFor failed: %v", visErr)
|
||||
errorMsg = "Error loading series"
|
||||
} else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] {
|
||||
errorMsg = "Library not accessible"
|
||||
} else {
|
||||
totalPages = (total + perSeriesPage - 1) / perSeriesPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
|
||||
for _, s := range seriesList {
|
||||
covers := s.CoverPaths
|
||||
if covers == nil {
|
||||
covers = []string{}
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesCardsData failed: %v", err)
|
||||
errorMsg = "Error loading series"
|
||||
} else {
|
||||
totalPages = (total + perSeriesPage - 1) / perSeriesPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
|
||||
for _, s := range seriesList {
|
||||
covers := s.CoverPaths
|
||||
if covers == nil {
|
||||
covers = []string{}
|
||||
}
|
||||
seriesCards = append(seriesCards, templates.SeriesCardData{
|
||||
Name: s.Name,
|
||||
LibraryID: s.LibraryID,
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: covers,
|
||||
})
|
||||
}
|
||||
seriesCards = append(seriesCards, templates.SeriesCardData{
|
||||
Name: s.Name,
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: covers,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,10 +213,28 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return renderErrorPage(c, "Series name required", "bad_request")
|
||||
}
|
||||
|
||||
// Series are per-library now: the detail page must say WHICH library's
|
||||
// series it is showing, and the requester must be able to see it.
|
||||
libraryIDParam := c.QueryParam("library_id")
|
||||
if libraryIDParam == "" {
|
||||
return renderErrorPage(c, "Library required", "bad_request")
|
||||
}
|
||||
libUUIDParam, err := uuid.Parse(libraryIDParam)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Invalid library", "bad_request")
|
||||
}
|
||||
visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
|
||||
if visErr != nil {
|
||||
return renderErrorPage(c, "Error checking access", "access_error")
|
||||
}
|
||||
if !visibleSet[libraryIDParam] {
|
||||
return renderErrorPage(c, "Access denied", "access_denied")
|
||||
}
|
||||
|
||||
var bookInfoList []handlers.BookInfo
|
||||
|
||||
svc := services.NewSeriesService(cfg.Queries)
|
||||
books, err := svc.GetSeriesBooks(c.Request().Context(), seriesName)
|
||||
books, err := svc.GetSeriesBooks(c.Request().Context(), pgtype.UUID{Bytes: libUUIDParam, Valid: true}, seriesName)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesBooks failed: %v", err)
|
||||
errorMsg = "Error loading series books"
|
||||
|
||||
@@ -3,6 +3,7 @@ package router
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
@@ -118,6 +119,15 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
||||
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
||||
libraries = []database.GetUserVisibleLibrariesRow{}
|
||||
}
|
||||
visibleSet := make(map[string]bool, len(libraries))
|
||||
for _, lib := range libraries {
|
||||
if !lib.ID.Valid {
|
||||
continue
|
||||
}
|
||||
if id, err := uuid.FromBytes(lib.ID.Bytes[0:16]); err == nil {
|
||||
visibleSet[id.String()] = true
|
||||
}
|
||||
}
|
||||
|
||||
counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU))
|
||||
if countErr != nil {
|
||||
@@ -189,8 +199,47 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
||||
return res
|
||||
}
|
||||
|
||||
// A requested library outside the requester's visible set is not
|
||||
// honored — fall back to the first visible library instead of leaking
|
||||
// hidden-library contents through a hand-typed query param or cookie.
|
||||
if !visibleSet[libraryID] {
|
||||
res.LibraryID = res.FirstID
|
||||
if res.LibraryID != "" {
|
||||
parsed, _ := uuid.Parse(res.LibraryID)
|
||||
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
res.LibraryID = libraryID
|
||||
parsed, _ := uuid.Parse(libraryID)
|
||||
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
return res
|
||||
}
|
||||
|
||||
// visibleLibraryIDsFor returns the requester's visible-library id set (for
|
||||
// membership checks) plus the ids themselves (for SQL ANY() filters).
|
||||
func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgtype.UUID, error) {
|
||||
userUUIDStr, ok := c.Get("user_id").(string)
|
||||
if !ok {
|
||||
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
userU, err := uuid.Parse(userUUIDStr)
|
||||
if err != nil {
|
||||
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userU))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
set := make(map[string]bool, len(libraries))
|
||||
ids := make([]pgtype.UUID, 0, len(libraries))
|
||||
for _, lib := range libraries {
|
||||
if !lib.ID.Valid {
|
||||
continue
|
||||
}
|
||||
set[lib.ID.String()] = true
|
||||
ids = append(ids, lib.ID)
|
||||
}
|
||||
return set, ids, nil
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import (
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type SeriesInfo struct {
|
||||
Name string
|
||||
LibraryID string
|
||||
BookCount int64
|
||||
TotalInSeries int
|
||||
CoverPaths []string
|
||||
@@ -24,16 +26,25 @@ func NewSeriesService(db *database.Queries) *SeriesService {
|
||||
return &SeriesService{db: db}
|
||||
}
|
||||
|
||||
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, limit, offset int) ([]SeriesInfo, int, error) {
|
||||
totalCount, err := s.db.GetDistinctSeriesCount(ctx, libraryID)
|
||||
// GetSeriesPage lists series grouped PER LIBRARY (same name in two
|
||||
// libraries = two entries) filtered to the requester's visible libraries.
|
||||
// Pass libraryID for a single-library page, or leave it invalid and pass
|
||||
// visibleLibraryIDs for an across-my-libraries page. Both filters compose:
|
||||
// the handler validates any explicit library_id against the visible set.
|
||||
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]SeriesInfo, int, error) {
|
||||
totalCount, err := s.db.GetDistinctSeriesCount(ctx, database.GetDistinctSeriesCountParams{
|
||||
LibraryID: libraryID,
|
||||
VisibleLibraryIds: visibleLibraryIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := s.db.GetDistinctSeries(ctx, database.GetDistinctSeriesParams{
|
||||
LibraryID: libraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
|
||||
LibraryID: libraryID,
|
||||
VisibleLibraryIds: visibleLibraryIDs,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -42,7 +53,7 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
|
||||
var series []SeriesInfo
|
||||
for _, row := range rows {
|
||||
seriesName := row.Series.String
|
||||
coverPaths, _ := s.GetSeriesCovers(ctx, libraryID, seriesName, 7)
|
||||
coverPaths, _ := s.GetSeriesCovers(ctx, row.LibraryID, seriesName, 7)
|
||||
|
||||
totalInSeries := 0
|
||||
if row.TotalInSeries != nil {
|
||||
@@ -71,6 +82,7 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
|
||||
|
||||
series = append(series, SeriesInfo{
|
||||
Name: seriesName,
|
||||
LibraryID: uuidFromPG(row.LibraryID),
|
||||
BookCount: row.BookCount,
|
||||
TotalInSeries: totalInSeries,
|
||||
CoverPaths: coverPaths,
|
||||
@@ -106,8 +118,28 @@ func (s *SeriesService) GetSeriesCovers(ctx context.Context, libraryID pgtype.UU
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func (s *SeriesService) GetSeriesBooks(ctx context.Context, seriesName string) ([]database.MediaItems, error) {
|
||||
return s.db.GetSeriesBooks(ctx, pgtype.Text{String: seriesName, Valid: true})
|
||||
func (s *SeriesService) GetSeriesBooks(ctx context.Context, libraryID pgtype.UUID, seriesName string) ([]database.MediaItems, error) {
|
||||
return s.db.GetSeriesBooks(ctx, database.GetSeriesBooksParams{
|
||||
Series: pgtype.Text{String: seriesName, Valid: true},
|
||||
LibraryID: libraryID,
|
||||
})
|
||||
}
|
||||
|
||||
// VisibleLibraries exposes the per-user visibility set used by the series
|
||||
// handlers to scope listings and validate explicit library_id params.
|
||||
func (s *SeriesService) VisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]database.GetUserVisibleLibrariesRow, error) {
|
||||
return s.db.GetUserVisibleLibraries(ctx, userID)
|
||||
}
|
||||
|
||||
func uuidFromPG(id pgtype.UUID) string {
|
||||
if !id.Valid {
|
||||
return ""
|
||||
}
|
||||
u, err := uuid.FromBytes(id.Bytes[0:16])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func continueSeriesRowToMediaItems(row database.GetContinueSeriesItemsRow) database.MediaItems {
|
||||
|
||||
@@ -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