Skip to content
Merged
35 changes: 34 additions & 1 deletion crates/codex-api/src/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,21 @@ The following paths are exempt from rate limiting:
v1::handlers::recommendations::refresh_recommendations,
v1::handlers::recommendations::dismiss_recommendation,

// Access Groups endpoints
v1::handlers::access_groups::list_access_groups,
v1::handlers::access_groups::get_access_group,
v1::handlers::access_groups::create_access_group,
v1::handlers::access_groups::update_access_group,
v1::handlers::access_groups::delete_access_group,
v1::handlers::access_groups::add_access_group_members,
v1::handlers::access_groups::remove_access_group_member,
v1::handlers::access_groups::get_user_access_groups,
v1::handlers::access_groups::add_access_group_grant,
v1::handlers::access_groups::remove_access_group_grant,
v1::handlers::access_groups::add_access_group_oidc_mapping,
v1::handlers::access_groups::remove_access_group_oidc_mapping,
v1::handlers::access_groups::get_user_effective_grants,

// Sharing Tags endpoints
v1::handlers::sharing_tags::list_sharing_tags,
v1::handlers::sharing_tags::get_sharing_tag,
Expand Down Expand Up @@ -799,6 +814,23 @@ The following paths are exempt from rate limiting:
v1::dto::BookExternalLinkListResponse,
v1::dto::CreateBookExternalLinkRequest,

// Access Group DTOs
v1::dto::AccessGroupDto,
v1::dto::AccessGroupDetailDto,
v1::dto::AccessGroupMemberDto,
v1::dto::AccessGroupGrantDto,
v1::dto::AccessGroupOidcMappingDto,
v1::dto::AccessGroupSummaryDto,
v1::dto::CreateAccessGroupRequest,
v1::dto::UpdateAccessGroupRequest,
v1::dto::AddAccessGroupMembersRequest,
v1::dto::AddAccessGroupGrantRequest,
v1::dto::AddAccessGroupOidcMappingRequest,
v1::dto::EffectiveGrantsResponse,
v1::dto::EffectiveGrantDto,
v1::dto::GrantSourceDto,
codex_db::entities::user_access_groups::MembershipSource,

// Sharing Tag DTOs
v1::dto::SharingTagDto,
v1::dto::SharingTagSummaryDto,
Expand Down Expand Up @@ -1133,6 +1165,7 @@ The following paths are exempt from rate limiting:
(name = "Observability", description = "Browser RUM bootstrap configuration and OTLP forwarding proxy"),
(name = "Filesystem", description = "Filesystem browsing for library paths"),
(name = "Duplicates", description = "Duplicate book detection and management"),
(name = "Access Groups", description = "Access group management for sharing-tag grants (admin only)"),
(name = "Sharing Tags", description = "Content access control tags (admin only)"),

// Real-time Events
Expand Down Expand Up @@ -1262,7 +1295,7 @@ impl utoipa::Modify for TagGroupsModifier {
},
{
"name": "Administration",
"tags": ["Admin", "Settings", "Plugins", "Plugin Actions", "Metrics", "Observability", "Filesystem", "Duplicates", "Sharing Tags"]
"tags": ["Admin", "Settings", "Plugins", "Plugin Actions", "Metrics", "Observability", "Filesystem", "Duplicates", "Access Groups", "Sharing Tags"]
},
{
"name": "Real-time Events",
Expand Down
69 changes: 46 additions & 23 deletions crates/codex-api/src/routes/komga/handlers/books.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use super::libraries::{extract_page_image, generate_thumbnail};
use crate::require_permission;
use crate::{
error::ApiError,
extractors::{AuthState, FlexibleAuthContext},
extractors::{AuthState, ContentFilter, FlexibleAuthContext},
permissions::Permission,
};
use axum::{
Expand Down Expand Up @@ -279,12 +279,23 @@ pub async fn get_books_ondeck(
let page = query.page.max(0) as u64;
let size = query.size.clamp(1, 500) as u64;

let content_filter = ContentFilter::for_user(&state.db, user_id)
.await
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
let visibility = content_filter.to_visibility();

// On Deck = first unread book in series where user completed at least one book
// and no books are currently in-progress. Uses the same logic as the v1 API.
let (books, total) =
BookRepository::list_on_deck(&state.db, user_id, query.library_id, page, size)
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch on-deck books: {}", e)))?;
let (books, total) = BookRepository::list_on_deck(
&state.db,
user_id,
query.library_id,
page,
size,
visibility.as_ref(),
)
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch on-deck books: {}", e)))?;

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

let user_id = Some(auth.user_id);

// Get adjacent books
let (_prev, next) = BookRepository::get_adjacent_in_series(&state.db, book_id)
let content_filter = ContentFilter::for_user(&state.db, auth.user_id)
.await
.map_err(|e| {
if e.to_string().contains("not found") {
ApiError::NotFound("Book not found".to_string())
} else {
ApiError::Internal(format!("Failed to get next book: {}", e))
}
})?;
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
let visibility = content_filter.to_visibility();

// Get adjacent books
let (_prev, next) =
BookRepository::get_adjacent_in_series(&state.db, book_id, visibility.as_ref())
.await
.map_err(|e| {
if e.to_string().contains("not found") {
ApiError::NotFound("Book not found".to_string())
} else {
ApiError::Internal(format!("Failed to get next book: {}", e))
}
})?;

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

Expand Down Expand Up @@ -622,16 +639,22 @@ pub async fn get_previous_book(

let user_id = Some(auth.user_id);

// Get adjacent books
let (prev, _next) = BookRepository::get_adjacent_in_series(&state.db, book_id)
let content_filter = ContentFilter::for_user(&state.db, auth.user_id)
.await
.map_err(|e| {
if e.to_string().contains("not found") {
ApiError::NotFound("Book not found".to_string())
} else {
ApiError::Internal(format!("Failed to get previous book: {}", e))
}
})?;
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
let visibility = content_filter.to_visibility();

// Get adjacent books
let (prev, _next) =
BookRepository::get_adjacent_in_series(&state.db, book_id, visibility.as_ref())
.await
.map_err(|e| {
if e.to_string().contains("not found") {
ApiError::NotFound("Book not found".to_string())
} else {
ApiError::Internal(format!("Failed to get previous book: {}", e))
}
})?;

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

Expand Down
2 changes: 1 addition & 1 deletion crates/codex-api/src/routes/komga/handlers/libraries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ pub async fn get_library_thumbnail(
.ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?;

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

Expand Down
9 changes: 7 additions & 2 deletions crates/codex-api/src/routes/opds/handlers/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use super::super::dto::{OpdsEntry, OpdsFeed, OpdsLink};
use crate::require_permission;
use crate::{
error::ApiError,
extractors::{AuthContext, AuthState},
extractors::{AuthContext, AuthState, ContentFilter},
permissions::Permission,
};
use axum::{
Expand Down Expand Up @@ -245,8 +245,13 @@ pub async fn library_series(
.map_err(|e| ApiError::Internal(format!("Failed to fetch library: {}", e)))?
.ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?;

let content_filter = ContentFilter::for_user(&state.db, auth.user_id)
.await
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
let visibility = content_filter.to_visibility();

// Fetch all series in library (no built-in pagination)
let all_series = SeriesRepository::list_by_library(&state.db, library_id)
let all_series = SeriesRepository::list_by_library(&state.db, library_id, visibility.as_ref())
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch series: {}", e)))?;

Expand Down
11 changes: 8 additions & 3 deletions crates/codex-api/src/routes/opds/handlers/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use super::super::dto::{OpdsEntry, OpdsFeed, OpdsLink};
use crate::require_permission;
use crate::{
error::ApiError,
extractors::{AuthContext, AuthState},
extractors::{AuthContext, AuthState, ContentFilter},
permissions::Permission,
};
use axum::{
Expand Down Expand Up @@ -127,6 +127,11 @@ pub async fn search(

let now = Utc::now();
let base_url = "/opds";

let content_filter = ContentFilter::for_user(&state.db, auth.user_id)
.await
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
let visibility = content_filter.to_visibility();
let query = params.q.trim();
let app_name = SettingsRepository::get_app_name(&state.db).await;

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

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

Expand Down Expand Up @@ -179,7 +184,7 @@ pub async fn search(
}

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

Expand Down
15 changes: 13 additions & 2 deletions crates/codex-api/src/routes/opds2/handlers/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use crate::require_permission;
use crate::{
error::ApiError,
extractors::{AuthContext, AuthState},
extractors::{AuthContext, AuthState, ContentFilter},
permissions::Permission,
routes::opds::handlers::OpdsPaginationParams,
};
Expand Down Expand Up @@ -175,8 +175,13 @@ pub async fn library_series(
.map_err(|e| ApiError::Internal(format!("Failed to fetch library: {}", e)))?
.ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?;

let content_filter = ContentFilter::for_user(&state.db, auth.user_id)
.await
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
let visibility = content_filter.to_visibility();

// Fetch all series in library
let all_series = SeriesRepository::list_by_library(&state.db, library_id)
let all_series = SeriesRepository::list_by_library(&state.db, library_id, visibility.as_ref())
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch series: {}", e)))?;

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

let content_filter = ContentFilter::for_user(&state.db, auth.user_id)
.await
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
let visibility = content_filter.to_visibility();

// Fetch recent books with their series
// page is 0-indexed
let (books, _total) = BookRepository::list_recently_added(
Expand All @@ -413,6 +423,7 @@ pub async fn recent(
false,
0,
pagination.page_size as u64,
visibility.as_ref(),
)
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch books: {}", e)))?;
Expand Down
14 changes: 9 additions & 5 deletions crates/codex-api/src/routes/opds2/handlers/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use crate::require_permission;
use crate::{
error::ApiError,
extractors::{AuthContext, AuthState},
extractors::{AuthContext, AuthState, ContentFilter},
permissions::Permission,
};
use axum::extract::{Query, State};
Expand Down Expand Up @@ -67,7 +67,7 @@ pub async fn search(
let mut publications: Vec<Publication> = Vec::new();

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

Expand Down Expand Up @@ -102,13 +102,17 @@ pub async fn search(
publications.push(pub_entry);
}

let user_id = auth.user_id;
let content_filter = ContentFilter::for_user(&state.db, user_id)
.await
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?;
let visibility = content_filter.to_visibility();

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

let user_id = auth.user_id;

// Add book entries
for book in books.iter().take(20) {
// Fetch book title from book_metadata
Expand Down
Loading
Loading