diff --git a/crates/pinakes-server/src/dto/media.rs b/crates/pinakes-server/src/dto/media.rs index e404776..4c16a17 100644 --- a/crates/pinakes-server/src/dto/media.rs +++ b/crates/pinakes-server/src/dto/media.rs @@ -16,22 +16,23 @@ pub fn relativize_path(full_path: &Path, roots: &[PathBuf]) -> String { let mut best: Option<&PathBuf> = None; for root in roots { if full_path.starts_with(root) { - let is_longer = best - .is_none_or(|b| root.components().count() > b.components().count()); + let is_longer = + best.is_none_or(|b| root.components().count() > b.components().count()); if is_longer { best = Some(root); } } } if let Some(root) = best - && let Ok(rel) = full_path.strip_prefix(root) { - // Normalise to forward slashes on all platforms. - return rel - .components() - .map(|c| c.as_os_str().to_string_lossy()) - .collect::>() - .join("/"); - } + && let Ok(rel) = full_path.strip_prefix(root) + { + // Normalise to forward slashes on all platforms. + return rel + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + } full_path.to_string_lossy().into_owned() } diff --git a/crates/pinakes-server/src/dto/search.rs b/crates/pinakes-server/src/dto/search.rs index eb9260e..dfe2576 100644 --- a/crates/pinakes-server/src/dto/search.rs +++ b/crates/pinakes-server/src/dto/search.rs @@ -1,7 +1,14 @@ +use pinakes_core::model::Pagination; use serde::{Deserialize, Serialize}; use super::media::MediaResponse; +/// Maximum offset accepted from clients. Prevents pathologically large OFFSET +/// values that cause expensive sequential scans in the database. +pub const MAX_OFFSET: u64 = 10_000_000; +/// Maximum page size accepted from most listing endpoints. +pub const MAX_LIMIT: u64 = 1000; + #[derive(Debug, Deserialize)] pub struct SearchParams { pub q: String, @@ -10,6 +17,17 @@ pub struct SearchParams { pub limit: Option, } +impl SearchParams { + #[must_use] + pub fn to_pagination(&self) -> Pagination { + Pagination::new( + self.offset.unwrap_or(0).min(MAX_OFFSET), + self.limit.unwrap_or(50).min(MAX_LIMIT), + None, + ) + } +} + #[derive(Debug, Serialize)] pub struct SearchResponse { pub items: Vec, @@ -25,6 +43,17 @@ pub struct SearchRequestBody { pub limit: Option, } +impl SearchRequestBody { + #[must_use] + pub fn to_pagination(&self) -> Pagination { + Pagination::new( + self.offset.unwrap_or(0).min(MAX_OFFSET), + self.limit.unwrap_or(50).min(MAX_LIMIT), + None, + ) + } +} + // Pagination #[derive(Debug, Deserialize)] pub struct PaginationParams { @@ -32,3 +61,14 @@ pub struct PaginationParams { pub limit: Option, pub sort: Option, } + +impl PaginationParams { + #[must_use] + pub fn to_pagination(&self) -> Pagination { + Pagination::new( + self.offset.unwrap_or(0).min(MAX_OFFSET), + self.limit.unwrap_or(50).min(MAX_LIMIT), + self.sort.clone(), + ) + } +} diff --git a/crates/pinakes-server/src/routes/audit.rs b/crates/pinakes-server/src/routes/audit.rs index 191c93f..7a32067 100644 --- a/crates/pinakes-server/src/routes/audit.rs +++ b/crates/pinakes-server/src/routes/audit.rs @@ -2,7 +2,6 @@ use axum::{ Json, extract::{Query, State}, }; -use pinakes_core::model::Pagination; use crate::{ dto::{AuditEntryResponse, PaginationParams}, @@ -14,11 +13,7 @@ pub async fn list_audit( State(state): State, Query(params): Query, ) -> Result>, ApiError> { - let pagination = Pagination::new( - params.offset.unwrap_or(0), - params.limit.unwrap_or(50).min(1000), - None, - ); + let pagination = params.to_pagination(); let entries = state.storage.list_audit_entries(None, &pagination).await?; Ok(Json( entries.into_iter().map(AuditEntryResponse::from).collect(), diff --git a/crates/pinakes-server/src/routes/books.rs b/crates/pinakes-server/src/routes/books.rs index 9e3a0bc..b8a1758 100644 --- a/crates/pinakes-server/src/routes/books.rs +++ b/crates/pinakes-server/src/routes/books.rs @@ -22,7 +22,7 @@ use uuid::Uuid; use crate::{ auth::resolve_user_id, - dto::MediaResponse, + dto::{MAX_OFFSET, MediaResponse}, error::ApiError, state::AppState, }; @@ -177,7 +177,7 @@ pub async fn list_books( Query(query): Query, ) -> Result { let pagination = Pagination { - offset: query.offset, + offset: query.offset.min(MAX_OFFSET), limit: query.limit.min(1000), sort: None, }; diff --git a/crates/pinakes-server/src/routes/search.rs b/crates/pinakes-server/src/routes/search.rs index 7f0e6b1..eacec6e 100644 --- a/crates/pinakes-server/src/routes/search.rs +++ b/crates/pinakes-server/src/routes/search.rs @@ -2,10 +2,7 @@ use axum::{ Json, extract::{Query, State}, }; -use pinakes_core::{ - model::Pagination, - search::{SearchRequest, SortOrder, parse_search_query}, -}; +use pinakes_core::search::{SearchRequest, SortOrder, parse_search_query}; use crate::{ dto::{MediaResponse, SearchParams, SearchRequestBody, SearchResponse}, @@ -43,11 +40,7 @@ pub async fn search( let request = SearchRequest { query, sort, - pagination: Pagination::new( - params.offset.unwrap_or(0), - params.limit.unwrap_or(50).min(1000), - None, - ), + pagination: params.to_pagination(), }; let results = state.storage.search(&request).await?; @@ -81,11 +74,7 @@ pub async fn search_post( let request = SearchRequest { query, sort, - pagination: Pagination::new( - body.offset.unwrap_or(0), - body.limit.unwrap_or(50).min(1000), - None, - ), + pagination: body.to_pagination(), }; let results = state.storage.search(&request).await?; diff --git a/crates/pinakes-server/src/routes/shares.rs b/crates/pinakes-server/src/routes/shares.rs index 76fea3c..39d00d9 100644 --- a/crates/pinakes-server/src/routes/shares.rs +++ b/crates/pinakes-server/src/routes/shares.rs @@ -207,11 +207,7 @@ pub async fn list_outgoing( Query(params): Query, ) -> ApiResult>> { let user_id = resolve_user_id(&state.storage, &username).await?; - let pagination = Pagination { - offset: params.offset.unwrap_or(0), - limit: params.limit.unwrap_or(50).min(1000), - sort: params.sort, - }; + let pagination = params.to_pagination(); let shares = state .storage @@ -230,11 +226,7 @@ pub async fn list_incoming( Query(params): Query, ) -> ApiResult>> { let user_id = resolve_user_id(&state.storage, &username).await?; - let pagination = Pagination { - offset: params.offset.unwrap_or(0), - limit: params.limit.unwrap_or(50).min(1000), - sort: params.sort, - }; + let pagination = params.to_pagination(); let shares = state .storage @@ -406,6 +398,9 @@ pub async fn batch_delete( Extension(username): Extension, Json(req): Json, ) -> ApiResult> { + if req.share_ids.is_empty() || req.share_ids.len() > 100 { + return Err(ApiError::bad_request("share_ids must contain 1-100 items")); + } let user_id = resolve_user_id(&state.storage, &username).await?; let share_ids: Vec = req.share_ids.into_iter().map(ShareId).collect(); @@ -624,11 +619,7 @@ pub async fn get_activity( )); } - let pagination = Pagination { - offset: params.offset.unwrap_or(0), - limit: params.limit.unwrap_or(50).min(1000), - sort: params.sort, - }; + let pagination = params.to_pagination(); let activity = state .storage