Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ cargo test -- --nocapture
```bash
# Format code
cargo fmt
cd web && npm run format
cd web && npx biome check --write .

# Lint code
cargo clippy -- -D warnings
Expand Down
20 changes: 20 additions & 0 deletions crates/codex-api/src/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -599,13 +599,17 @@ The following paths are exempt from rate limiting:
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::add_all_users_to_access_group,
v1::handlers::access_groups::remove_access_group_member,
v1::handlers::access_groups::get_user_access_groups,
v1::handlers::access_groups::get_access_group_rules,
v1::handlers::access_groups::replace_access_group_rules,
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,
v1::handlers::access_groups::get_visibility_preview,

// Sharing Tags endpoints
v1::handlers::sharing_tags::list_sharing_tags,
Expand Down Expand Up @@ -962,6 +966,21 @@ The following paths are exempt from rate limiting:
v1::dto::AccessGroupSummaryDto,
v1::dto::CreateAccessGroupRequest,
v1::dto::UpdateAccessGroupRequest,
v1::dto::AddAllUsersResponse,
v1::dto::AccessGroupRulesDto,
v1::dto::VisibilityPreviewDto,
v1::dto::LibraryVisibilityDto,
v1::dto::SeriesAccessDto,
v1::dto::MatchedRuleDto,
codex_services::content_filter::AccessDecision,
codex_services::content_filter::RuleTypeLabel,
v1::dto::SharingTagRuleDto,
v1::dto::NameRuleDto,
v1::dto::LibraryRuleDto,
v1::dto::ReplaceAccessGroupRulesRequest,
v1::dto::SharingTagRuleInput,
v1::dto::LibraryRuleInput,
codex_db::entities::access_groups::RuleMatch,
v1::dto::AddAccessGroupMembersRequest,
v1::dto::AddAccessGroupGrantRequest,
v1::dto::AddAccessGroupOidcMappingRequest,
Expand Down Expand Up @@ -1008,6 +1027,7 @@ The following paths are exempt from rate limiting:
v1::dto::RetryErrorsResponse,
v1::dto::PageDto,
v1::dto::UserDto,
v1::dto::UserAccessGroupDto,
v1::dto::UserDetailDto,
v1::dto::CreateUserRequest,
v1::dto::UpdateUserRequest,
Expand Down
1 change: 1 addition & 0 deletions crates/codex-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod page_extract;
pub mod permissions;
pub mod ranged_file;
pub mod routes;
pub mod visibility;
pub mod web;

#[allow(unused_imports)]
Expand Down
11 changes: 8 additions & 3 deletions crates/codex-api/src/routes/komga/handlers/books.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ pub async fn get_book(
Path(book_id): Path<Uuid>,
) -> Result<Json<KomgaBookDto>, ApiError> {
require_permission!(auth, Permission::BooksRead)?;
crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?;

let user_id = Some(auth.user_id);

Expand Down Expand Up @@ -207,6 +208,7 @@ pub async fn get_book_thumbnail(
Path(book_id): Path<Uuid>,
) -> Result<Response, ApiError> {
require_permission!(auth, Permission::BooksRead)?;
crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?;

// Serve from the on-disk thumbnail cache without touching the database.
// A Komga client painting a shelf asks for every cover at once, and the
Expand Down Expand Up @@ -342,7 +344,7 @@ pub async fn get_books_ondeck(
let size = query.size.clamp(1, 500) as u64;
let offset = page * size;

let content_filter = ContentFilter::for_user(&state.db, user_id)
let content_filter = ContentFilter::cached_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();
Expand Down Expand Up @@ -510,6 +512,7 @@ pub async fn search_books(
.or(body.search_term.as_deref().filter(|s| !s.is_empty()));

// Use composable query with database-level sorting
let visibility = crate::visibility::user_visibility(&state.db, user_id).await?;
let options = BookQueryOptions {
library_id,
series_id,
Expand All @@ -521,6 +524,7 @@ pub async fn search_books(
sort,
page,
page_size: size,
visibility: visibility.as_ref(),
};

let (books, total) = BookRepository::query(&state.db, options)
Expand Down Expand Up @@ -609,7 +613,7 @@ pub async fn get_next_book(

let user_id = Some(auth.user_id);

let content_filter = ContentFilter::for_user(&state.db, auth.user_id)
let content_filter = ContentFilter::cached_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();
Expand Down Expand Up @@ -701,7 +705,7 @@ pub async fn get_previous_book(

let user_id = Some(auth.user_id);

let content_filter = ContentFilter::for_user(&state.db, auth.user_id)
let content_filter = ContentFilter::cached_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();
Expand Down Expand Up @@ -787,6 +791,7 @@ pub async fn download_book_file(
Path(book_id): Path<Uuid>,
) -> Result<Response, ApiError> {
require_permission!(auth, Permission::BooksRead)?;
crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?;

// Fetch book from database
let book = BookRepository::get_by_id(&state.db, book_id)
Expand Down
24 changes: 8 additions & 16 deletions crates/codex-api/src/routes/komga/handlers/collections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ use super::super::dto::series::KomgaSeriesDto;
use super::super::dto::stubs::{KomgaCollectionDto, StubPaginationQuery};
use super::series::{build_series_dto, generate_series_thumbnail};
use crate::require_permission;
use crate::visibility::user_visibility;
use crate::{
error::ApiError,
extractors::{AuthState, ContentFilter, FlexibleAuthContext},
extractors::{AuthState, FlexibleAuthContext},
permissions::Permission,
};
use axum::{
Expand Down Expand Up @@ -97,16 +98,6 @@ fn parse_komga_collection_sort(sort: Option<&str>) -> Option<SeriesSortParam> {
Some(SeriesSortParam { field, direction })
}

async fn user_visibility(
state: &AuthState,
user_id: Uuid,
) -> Result<Option<SeriesVisibility>, ApiError> {
let filter = ContentFilter::for_user(&state.db, user_id)
.await
.map_err(|e| ApiError::Internal(format!("Failed to load content filter: {e}")))?;
Ok(filter.to_visibility())
}

/// Created/modified dates for a virtual queue-backed DTO: first and last
/// `added_at` of the user's queue entries, or "now" for an empty queue.
pub(crate) fn queue_dates(entries: &[codex_db::entities::want_to_read::Model]) -> (String, String) {
Expand Down Expand Up @@ -190,7 +181,7 @@ pub async fn list_collections(
Query(query): Query<StubPaginationQuery>,
) -> Result<Json<KomgaPage<KomgaCollectionDto>>, ApiError> {
require_permission!(auth, Permission::SeriesRead)?;
let vis = user_visibility(&state, auth.user_id).await?;
let vis = user_visibility(&state.db, auth.user_id).await?;

let collections = CollectionRepository::list_all(&state.db)
.await
Expand Down Expand Up @@ -234,7 +225,7 @@ pub async fn get_collection(
Path(collection_id): Path<String>,
) -> Result<Json<KomgaCollectionDto>, ApiError> {
require_permission!(auth, Permission::SeriesRead)?;
let vis = user_visibility(&state, auth.user_id).await?;
let vis = user_visibility(&state.db, auth.user_id).await?;
if collection_id == WANT_TO_READ_ID {
return Ok(Json(
build_want_to_read_dto(&state, auth.user_id, vis.as_ref()).await?,
Expand Down Expand Up @@ -270,7 +261,7 @@ pub async fn get_collection_series(
Query(query): Query<StubPaginationQuery>,
) -> Result<Json<KomgaPage<KomgaSeriesDto>>, ApiError> {
require_permission!(auth, Permission::SeriesRead)?;
let vis = user_visibility(&state, auth.user_id).await?;
let vis = user_visibility(&state.db, auth.user_id).await?;
let members = if collection_id == WANT_TO_READ_ID {
WantToReadRepository::queued_series(&state.db, auth.user_id, vis.as_ref())
.await
Expand Down Expand Up @@ -353,7 +344,7 @@ pub async fn get_collection_thumbnail(
Path(collection_id): Path<String>,
) -> Result<Response, ApiError> {
auth.require_permission(&Permission::SeriesRead)?;
let vis = user_visibility(&state, auth.user_id).await?;
let vis = user_visibility(&state.db, auth.user_id).await?;
let members = if collection_id == WANT_TO_READ_ID {
WantToReadRepository::queued_series(&state.db, auth.user_id, vis.as_ref())
.await
Expand Down Expand Up @@ -475,7 +466,8 @@ pub async fn get_series_collections(
require_permission!(auth, Permission::SeriesRead)?;
let sid = Uuid::parse_str(&series_id)
.map_err(|_| ApiError::NotFound("Series not found".to_string()))?;
let vis = user_visibility(&state, auth.user_id).await?;
crate::visibility::ensure_series_visible(&state.db, auth.user_id, sid).await?;
let vis = user_visibility(&state.db, auth.user_id).await?;

let collections = CollectionRepository::get_collections_for_series(&state.db, sid)
.await
Expand Down
19 changes: 16 additions & 3 deletions crates/codex-api/src/routes/komga/handlers/libraries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ pub async fn list_libraries(
let libraries = LibraryRepository::list_all(&state.db)
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch libraries: {}", e)))?;
let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?;
let libraries: Vec<_> = libraries
.into_iter()
.filter(|library| filter.is_library_visible(library.id))
.collect();

let dtos: Vec<KomgaLibraryDto> = libraries
.into_iter()
Expand Down Expand Up @@ -113,6 +118,7 @@ pub async fn get_library(
Path(library_id): Path<Uuid>,
) -> Result<Json<KomgaLibraryDto>, ApiError> {
require_permission!(auth, Permission::LibrariesRead)?;
crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?;

let library = LibraryRepository::get_by_id(&state.db, library_id)
.await
Expand Down Expand Up @@ -166,6 +172,7 @@ pub async fn get_library_thumbnail(
Path(library_id): Path<Uuid>,
) -> Result<Response, ApiError> {
require_permission!(auth, Permission::LibrariesRead)?;
crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?;

// Verify library exists
let _library = LibraryRepository::get_by_id(&state.db, library_id)
Expand All @@ -174,9 +181,15 @@ 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, None)
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch series: {}", e)))?;
let series_list = SeriesRepository::list_by_library(
&state.db,
library_id,
crate::visibility::user_visibility(&state.db, auth.user_id)
.await?
.as_ref(),
)
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch series: {}", e)))?;

let first_series = series_list
.into_iter()
Expand Down
2 changes: 2 additions & 0 deletions crates/codex-api/src/routes/komga/handlers/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ pub async fn get_epub_manifest(
Path(book_id): Path<Uuid>,
) -> Result<Response, ApiError> {
require_permission!(auth, Permission::BooksRead)?;
crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?;

let book = BookRepository::get_by_id(&state.db, book_id)
.await
Expand Down Expand Up @@ -407,6 +408,7 @@ pub async fn get_epub_resource(
Path((book_id, resource)): Path<(Uuid, String)>,
) -> Result<Response, ApiError> {
require_permission!(auth, Permission::BooksRead)?;
crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?;

// Decode percent-encoded path; strip leading '/' from wildcard capture
let resource = resource.strip_prefix('/').unwrap_or(&resource);
Expand Down
3 changes: 3 additions & 0 deletions crates/codex-api/src/routes/komga/handlers/pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ pub async fn list_pages(
Path(book_id): Path<Uuid>,
) -> Result<Json<Vec<KomgaPageDto>>, ApiError> {
require_permission!(auth, Permission::BooksRead)?;
crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?;

// Verify book exists and get book info
let book = BookRepository::get_by_id(&state.db, book_id)
Expand Down Expand Up @@ -150,6 +151,7 @@ pub async fn get_page(
Path((book_id, page_number)): Path<(Uuid, i32)>,
) -> Result<Response, ApiError> {
require_permission!(auth, Permission::BooksRead)?;
crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?;

// Validate page number
if page_number < 1 {
Expand Down Expand Up @@ -245,6 +247,7 @@ pub async fn get_page_thumbnail(
Path((book_id, page_number)): Path<(Uuid, i32)>,
) -> Result<Response, ApiError> {
require_permission!(auth, Permission::BooksRead)?;
crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?;

// Validate page number
if page_number < 1 {
Expand Down
6 changes: 6 additions & 0 deletions crates/codex-api/src/routes/komga/handlers/read_progress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ pub async fn update_progress(
axum::Json(request): axum::Json<KomgaReadProgressUpdateDto>,
) -> Result<StatusCode, ApiError> {
require_permission!(auth, Permission::ProgressWrite)?;
crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?;

let user_id = auth.user_id;

Expand Down Expand Up @@ -154,6 +155,7 @@ pub async fn delete_progress(
Path(book_id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
require_permission!(auth, Permission::ProgressWrite)?;
crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?;

let user_id = auth.user_id;

Expand Down Expand Up @@ -251,6 +253,7 @@ pub async fn get_progression(
Path(book_id): Path<Uuid>,
) -> Result<Response, ApiError> {
require_permission!(auth, Permission::ProgressRead)?;
crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?;

// Verify book exists
BookRepository::get_by_id(&state.db, book_id)
Expand Down Expand Up @@ -312,6 +315,7 @@ pub async fn put_progression(
axum::Json(body): axum::Json<serde_json::Value>,
) -> Result<StatusCode, ApiError> {
require_permission!(auth, Permission::ProgressWrite)?;
crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?;

let book = BookRepository::get_by_id(&state.db, book_id)
.await
Expand Down Expand Up @@ -446,6 +450,7 @@ pub async fn mark_series_as_read(
Path(series_id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
require_permission!(auth, Permission::ProgressWrite)?;
crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?;

let user_id = auth.user_id;

Expand Down Expand Up @@ -518,6 +523,7 @@ pub async fn mark_series_as_unread(
Path(series_id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
require_permission!(auth, Permission::ProgressWrite)?;
crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?;

let user_id = auth.user_id;

Expand Down
Loading
Loading