Skip to content

Commit f5c8ea6

Browse files
committed
fix(access-groups): enforce sharing-tag visibility on book and series list endpoints
Sharing-tag access control was only enforced on a subset of read endpoints. The /api/v1/series listing filtered by ContentFilter, but /api/v1/books, POST /api/v1/books/list, /books/recently-added, /books/on-deck, /books/in-progress, /books/recently-read, /books/{id}/adjacent, the library-scoped variants of all of the above, plus the OPDS, OPDS 2.0, and Komga book paths, all returned books in series the caller had been denied access to. The series handlers that did filter were doing it in memory after the repo returned every row, so paginated totals reflected the unfiltered set even though the page itself dropped denied rows. Pagination links became wrong once any deny grant was active. This change moves visibility enforcement into the SQL layer: - New SeriesVisibility helper in codex-db: a deny set plus an optional allow set (whitelist mode). visibility_predicate() builds the SeaORM SimpleExpr; apply_book_visibility / apply_series_visibility add it to a Select<books::Entity> / Select<series::Entity>. Empty whitelists short-circuit to an empty result without touching the DB. - ContentFilter::to_visibility() turns the resolved per-user grants into a SeriesVisibility, returning None when the user has no restrictions so the natural query is unchanged for the common case. - Every BookRepository and SeriesRepository method used by user-facing list / search endpoints now takes Option<&SeriesVisibility>: list_all, list_recently_added, list_by_ids, list_by_ids_sorted, hydrate_by_ids, list_by_library_sorted, list_with_progress, list_recently_read, list_on_deck, search_by_title, search_by_name, get_adjacent_in_series for books; list_by_library, list_all, list_recently_added, list_recently_updated, list_by_library_sorted, list_by_ids_sorted, search_by_title, search_by_name, list_in_progress for series. - All affected v1, OPDS, OPDS 2.0, and Komga handlers load the ContentFilter once and thread visibility through. The in-memory filter passes in series handlers are gone, so totals reflect the user's view. - Internal callers that should not be visibility-scoped (scanner, thumbnail generation, library reprocess, plugin auto-match) pass None explicitly. Tests cover the SeriesVisibility helper, every affected repository method (deny mode, whitelist mode, empty-whitelist short-circuit, and None pass-through), and the user-facing endpoints end-to-end through sharing-tag deny and whitelist grants.
1 parent d6ede2f commit f5c8ea6

39 files changed

Lines changed: 1921 additions & 356 deletions

crates/codex-api/src/routes/komga/handlers/books.rs

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use super::libraries::{extract_page_image, generate_thumbnail};
1212
use crate::require_permission;
1313
use crate::{
1414
error::ApiError,
15-
extractors::{AuthState, FlexibleAuthContext},
15+
extractors::{AuthState, ContentFilter, FlexibleAuthContext},
1616
permissions::Permission,
1717
};
1818
use axum::{
@@ -279,12 +279,23 @@ pub async fn get_books_ondeck(
279279
let page = query.page.max(0) as u64;
280280
let size = query.size.clamp(1, 500) as u64;
281281

282+
let content_filter = ContentFilter::for_user(&state.db, user_id)
283+
.await
284+
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
285+
let visibility = content_filter.to_visibility();
286+
282287
// On Deck = first unread book in series where user completed at least one book
283288
// and no books are currently in-progress. Uses the same logic as the v1 API.
284-
let (books, total) =
285-
BookRepository::list_on_deck(&state.db, user_id, query.library_id, page, size)
286-
.await
287-
.map_err(|e| ApiError::Internal(format!("Failed to fetch on-deck books: {}", e)))?;
289+
let (books, total) = BookRepository::list_on_deck(
290+
&state.db,
291+
user_id,
292+
query.library_id,
293+
page,
294+
size,
295+
visibility.as_ref(),
296+
)
297+
.await
298+
.map_err(|e| ApiError::Internal(format!("Failed to fetch on-deck books: {}", e)))?;
288299

289300
// Batch-fetch book metadata for all books
290301
let book_ids: Vec<Uuid> = books.iter().map(|b| b.id).collect();
@@ -536,16 +547,22 @@ pub async fn get_next_book(
536547

537548
let user_id = Some(auth.user_id);
538549

539-
// Get adjacent books
540-
let (_prev, next) = BookRepository::get_adjacent_in_series(&state.db, book_id)
550+
let content_filter = ContentFilter::for_user(&state.db, auth.user_id)
541551
.await
542-
.map_err(|e| {
543-
if e.to_string().contains("not found") {
544-
ApiError::NotFound("Book not found".to_string())
545-
} else {
546-
ApiError::Internal(format!("Failed to get next book: {}", e))
547-
}
548-
})?;
552+
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
553+
let visibility = content_filter.to_visibility();
554+
555+
// Get adjacent books
556+
let (_prev, next) =
557+
BookRepository::get_adjacent_in_series(&state.db, book_id, visibility.as_ref())
558+
.await
559+
.map_err(|e| {
560+
if e.to_string().contains("not found") {
561+
ApiError::NotFound("Book not found".to_string())
562+
} else {
563+
ApiError::Internal(format!("Failed to get next book: {}", e))
564+
}
565+
})?;
549566

550567
let next_book = next.ok_or_else(|| ApiError::NotFound("No next book".to_string()))?;
551568

@@ -622,16 +639,22 @@ pub async fn get_previous_book(
622639

623640
let user_id = Some(auth.user_id);
624641

625-
// Get adjacent books
626-
let (prev, _next) = BookRepository::get_adjacent_in_series(&state.db, book_id)
642+
let content_filter = ContentFilter::for_user(&state.db, auth.user_id)
627643
.await
628-
.map_err(|e| {
629-
if e.to_string().contains("not found") {
630-
ApiError::NotFound("Book not found".to_string())
631-
} else {
632-
ApiError::Internal(format!("Failed to get previous book: {}", e))
633-
}
634-
})?;
644+
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
645+
let visibility = content_filter.to_visibility();
646+
647+
// Get adjacent books
648+
let (prev, _next) =
649+
BookRepository::get_adjacent_in_series(&state.db, book_id, visibility.as_ref())
650+
.await
651+
.map_err(|e| {
652+
if e.to_string().contains("not found") {
653+
ApiError::NotFound("Book not found".to_string())
654+
} else {
655+
ApiError::Internal(format!("Failed to get previous book: {}", e))
656+
}
657+
})?;
635658

636659
let prev_book = prev.ok_or_else(|| ApiError::NotFound("No previous book".to_string()))?;
637660

crates/codex-api/src/routes/komga/handlers/libraries.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ pub async fn get_library_thumbnail(
174174
.ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?;
175175

176176
// Get the first series in this library to use as the thumbnail
177-
let series_list = SeriesRepository::list_by_library(&state.db, library_id)
177+
let series_list = SeriesRepository::list_by_library(&state.db, library_id, None)
178178
.await
179179
.map_err(|e| ApiError::Internal(format!("Failed to fetch series: {}", e)))?;
180180

crates/codex-api/src/routes/opds/handlers/catalog.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use super::super::dto::{OpdsEntry, OpdsFeed, OpdsLink};
22
use crate::require_permission;
33
use crate::{
44
error::ApiError,
5-
extractors::{AuthContext, AuthState},
5+
extractors::{AuthContext, AuthState, ContentFilter},
66
permissions::Permission,
77
};
88
use axum::{
@@ -245,8 +245,13 @@ pub async fn library_series(
245245
.map_err(|e| ApiError::Internal(format!("Failed to fetch library: {}", e)))?
246246
.ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?;
247247

248+
let content_filter = ContentFilter::for_user(&state.db, auth.user_id)
249+
.await
250+
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
251+
let visibility = content_filter.to_visibility();
252+
248253
// Fetch all series in library (no built-in pagination)
249-
let all_series = SeriesRepository::list_by_library(&state.db, library_id)
254+
let all_series = SeriesRepository::list_by_library(&state.db, library_id, visibility.as_ref())
250255
.await
251256
.map_err(|e| ApiError::Internal(format!("Failed to fetch series: {}", e)))?;
252257

crates/codex-api/src/routes/opds/handlers/search.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use super::super::dto::{OpdsEntry, OpdsFeed, OpdsLink};
22
use crate::require_permission;
33
use crate::{
44
error::ApiError,
5-
extractors::{AuthContext, AuthState},
5+
extractors::{AuthContext, AuthState, ContentFilter},
66
permissions::Permission,
77
};
88
use axum::{
@@ -127,6 +127,11 @@ pub async fn search(
127127

128128
let now = Utc::now();
129129
let base_url = "/opds";
130+
131+
let content_filter = ContentFilter::for_user(&state.db, auth.user_id)
132+
.await
133+
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
134+
let visibility = content_filter.to_visibility();
130135
let query = params.q.trim();
131136
let app_name = SettingsRepository::get_app_name(&state.db).await;
132137

@@ -150,7 +155,7 @@ pub async fn search(
150155
.add_link(OpdsLink::start_link(base_url.to_string()));
151156

152157
// Search series by name
153-
let series_list = SeriesRepository::search_by_name(&state.db, query)
158+
let series_list = SeriesRepository::search_by_name(&state.db, query, None)
154159
.await
155160
.map_err(|e| ApiError::Internal(format!("Failed to search series: {}", e)))?;
156161

@@ -179,7 +184,7 @@ pub async fn search(
179184
}
180185

181186
// Search books by name/title
182-
let books = BookRepository::search_by_name(&state.db, query)
187+
let books = BookRepository::search_by_name(&state.db, query, visibility.as_ref())
183188
.await
184189
.map_err(|e| ApiError::Internal(format!("Failed to search books: {}", e)))?;
185190

crates/codex-api/src/routes/opds2/handlers/catalog.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
use crate::require_permission;
66
use crate::{
77
error::ApiError,
8-
extractors::{AuthContext, AuthState},
8+
extractors::{AuthContext, AuthState, ContentFilter},
99
permissions::Permission,
1010
routes::opds::handlers::OpdsPaginationParams,
1111
};
@@ -175,8 +175,13 @@ pub async fn library_series(
175175
.map_err(|e| ApiError::Internal(format!("Failed to fetch library: {}", e)))?
176176
.ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?;
177177

178+
let content_filter = ContentFilter::for_user(&state.db, auth.user_id)
179+
.await
180+
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
181+
let visibility = content_filter.to_visibility();
182+
178183
// Fetch all series in library
179-
let all_series = SeriesRepository::list_by_library(&state.db, library_id)
184+
let all_series = SeriesRepository::list_by_library(&state.db, library_id, visibility.as_ref())
180185
.await
181186
.map_err(|e| ApiError::Internal(format!("Failed to fetch series: {}", e)))?;
182187

@@ -405,6 +410,11 @@ pub async fn recent(
405410
let pagination = pagination.validate(50);
406411
let base_url = "/opds/v2";
407412

413+
let content_filter = ContentFilter::for_user(&state.db, auth.user_id)
414+
.await
415+
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
416+
let visibility = content_filter.to_visibility();
417+
408418
// Fetch recent books with their series
409419
// page is 0-indexed
410420
let (books, _total) = BookRepository::list_recently_added(
@@ -413,6 +423,7 @@ pub async fn recent(
413423
false,
414424
0,
415425
pagination.page_size as u64,
426+
visibility.as_ref(),
416427
)
417428
.await
418429
.map_err(|e| ApiError::Internal(format!("Failed to fetch books: {}", e)))?;

crates/codex-api/src/routes/opds2/handlers/search.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
use crate::require_permission;
66
use crate::{
77
error::ApiError,
8-
extractors::{AuthContext, AuthState},
8+
extractors::{AuthContext, AuthState, ContentFilter},
99
permissions::Permission,
1010
};
1111
use axum::extract::{Query, State};
@@ -67,7 +67,7 @@ pub async fn search(
6767
let mut publications: Vec<Publication> = Vec::new();
6868

6969
// Search series by name and add as navigation-like entries with links to books
70-
let series_list = SeriesRepository::search_by_name(&state.db, query)
70+
let series_list = SeriesRepository::search_by_name(&state.db, query, None)
7171
.await
7272
.map_err(|e| ApiError::Internal(format!("Failed to search series: {}", e)))?;
7373

@@ -102,13 +102,17 @@ pub async fn search(
102102
publications.push(pub_entry);
103103
}
104104

105+
let user_id = auth.user_id;
106+
let content_filter = ContentFilter::for_user(&state.db, user_id)
107+
.await
108+
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
109+
let visibility = content_filter.to_visibility();
110+
105111
// Search books by name/title
106-
let books = BookRepository::search_by_name(&state.db, query)
112+
let books = BookRepository::search_by_name(&state.db, query, visibility.as_ref())
107113
.await
108114
.map_err(|e| ApiError::Internal(format!("Failed to search books: {}", e)))?;
109115

110-
let user_id = auth.user_id;
111-
112116
// Add book entries
113117
for book in books.iter().take(20) {
114118
// Fetch book title from book_metadata

0 commit comments

Comments
 (0)