From 9d5c639dc4dedecc2a221d7d71ae14380d2c31e6 Mon Sep 17 00:00:00 2001 From: Mario Date: Sat, 14 Dec 2024 13:23:50 +0100 Subject: [PATCH 01/14] feat: [#658] new database method to get all user profiles --- src/databases/database.rs | 3 +++ src/databases/mysql.rs | 9 ++++++++- src/databases/sqlite.rs | 7 +++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/databases/database.rs b/src/databases/database.rs index 84dd1860..40487b61 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -143,6 +143,9 @@ pub trait Database: Sync + Send { /// Get `UserProfile` from `username`. async fn get_user_profile_from_username(&self, username: &str) -> Result; + /// Get all user profiles as `Vec`. + async fn get_user_profiles(&self) -> Result, Error>; + /// Get `UserCompact` from `user_id`. async fn get_user_compact_from_id(&self, user_id: i64) -> Result; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index ba8afc6d..af8a764b 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -148,13 +148,20 @@ impl Database for Mysql { } async fn get_user_profile_from_username(&self, username: &str) -> Result { - query_as::<_, UserProfile>(r#"SELECT user_id, username, COALESCE(email, "") as email, email_verified, COALESCE(bio, "") as bio, COALESCE(avatar, "") as avatar FROM torrust_user_profiles WHERE username = ?"#) + query_as::<_, UserProfile>(r#"SELECT user_id, username, COALESCE(email, "") as email FROM torrust_user_profiles"#) .bind(username) .fetch_one(&self.pool) .await .map_err(|_| database::Error::UserNotFound) } + async fn get_user_profiles(&self) -> Result, database::Error> { + query_as::<_, UserProfile>(r#"SELECT * FROM torrust_user_profiles"#) + .fetch_all(&self.pool) + .await + .map_err(|_| database::Error::Error) + } + async fn get_user_compact_from_id(&self, user_id: i64) -> Result { query_as::<_, UserCompact>("SELECT tu.user_id, tp.username, tu.administrator FROM torrust_users tu INNER JOIN torrust_user_profiles tp ON tu.user_id = tp.user_id WHERE tu.user_id = ?") .bind(user_id) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index b1e20005..8b91c155 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -156,6 +156,13 @@ impl Database for Sqlite { .map_err(|_| database::Error::UserNotFound) } + async fn get_user_profiles(&self) -> Result, database::Error> { + query_as::<_, UserProfile>("SELECT * FROM torrust_user_profiles") + .fetch_all(&self.pool) + .await + .map_err(|_| database::Error::Error) + } + async fn get_user_compact_from_id(&self, user_id: i64) -> Result { query_as::<_, UserCompact>("SELECT tu.user_id, tp.username, tu.administrator FROM torrust_users tu INNER JOIN torrust_user_profiles tp ON tu.user_id = tp.user_id WHERE tu.user_id = ?") .bind(user_id) From f48c4a29033e4f7cd562e831b71f7d23a74c063d Mon Sep 17 00:00:00 2001 From: Mario Date: Sun, 5 Jan 2025 19:21:47 +0100 Subject: [PATCH 02/14] feat: [#658] new repository method to get all user profiles --- src/services/user.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/services/user.rs b/src/services/user.rs index fd9ab995..317187bf 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -412,6 +412,15 @@ impl DbUserProfileRepository { pub async fn get_user_profile_from_username(&self, username: &str) -> Result { self.database.get_user_profile_from_username(username).await } + + /// It gets all the user profiles for all the users. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn get_all_user_profiles(&self) -> Result, Error> { + self.database.get_user_profiles().await + } } pub struct DbBannedUserList { From deb738eaad7cad9f474bd2cb17a4e340c4c9c532 Mon Sep 17 00:00:00 2001 From: Mario Date: Wed, 8 Jan 2025 16:52:07 +0100 Subject: [PATCH 03/14] feat: [#658] new users listing service --- src/services/authorization.rs | 2 ++ src/services/user.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/services/authorization.rs b/src/services/authorization.rs index e6678ac1..01dfebfd 100644 --- a/src/services/authorization.rs +++ b/src/services/authorization.rs @@ -52,6 +52,7 @@ pub enum ACTION { GetCanonicalInfoHash, ChangePassword, BanUser, + GetUsers, } pub struct Service { @@ -248,6 +249,7 @@ impl Default for CasbinConfiguration { admin, GetCanonicalInfoHash admin, ChangePassword admin, BanUser + admin, GetUsers registered, GetAboutPage registered, GetLicensePage registered, GetCategories diff --git a/src/services/user.rs b/src/services/user.rs index 317187bf..98eaae12 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -321,6 +321,39 @@ impl BanService { } } +pub struct ListingService { + user_profile_repository: Arc, + authorization_service: Arc, +} + +impl ListingService { + #[must_use] + pub fn new( + user_profile_repository: Arc, + authorization_service: Arc, + ) -> Self { + Self { + user_profile_repository, + authorization_service, + } + } + + /// Gets all users. + /// + /// # Errors + /// + /// This function will return a: + /// + /// * There is a database error retrieving the users + pub async fn get_all(&self, maybe_user_id: Option) -> Result, ServiceError> { + self.authorization_service.authorize(ACTION::GetUsers, maybe_user_id).await?; + + let users = self.user_profile_repository.get_all_user_profiles().await?; + + Ok(users) + } +} + #[cfg_attr(test, automock)] #[async_trait] pub trait Repository: Sync + Send { From e8bd16df68b14c1aacf2533be641b01e7339917b Mon Sep 17 00:00:00 2001 From: Mario Date: Wed, 8 Jan 2025 17:03:00 +0100 Subject: [PATCH 04/14] ci: [#658] fixed minor linting error --- src/databases/mysql.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index af8a764b..6d529680 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -156,7 +156,7 @@ impl Database for Mysql { } async fn get_user_profiles(&self) -> Result, database::Error> { - query_as::<_, UserProfile>(r#"SELECT * FROM torrust_user_profiles"#) + query_as::<_, UserProfile>(r"SELECT * FROM torrust_user_profiles") .fetch_all(&self.pool) .await .map_err(|_| database::Error::Error) From b44ea9b16a5efb8451857e0be08e1e3f2154fe46 Mon Sep 17 00:00:00 2001 From: Mario Date: Wed, 8 Jan 2025 17:51:19 +0100 Subject: [PATCH 05/14] feat: [#658] New API endpoint for getting all the users --- src/app.rs | 7 +++++ src/common.rs | 3 +++ .../api/server/v1/contexts/user/handlers.rs | 27 +++++++++++++++++++ src/web/api/server/v1/contexts/user/routes.rs | 9 +++++-- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index 9ae58cd8..2448ffd6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -160,6 +160,12 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running let about_service = Arc::new(about::Service::new(authorization_service.clone())); + let listing_service = Arc::new(user::ListingService::new( + user_profile_repository.clone(), + authorization_service.clone(), + )) + .clone(); + // Build app container let app_data = Arc::new(AppData::new( @@ -194,6 +200,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running profile_service, ban_service, about_service, + listing_service, )); // Start cronjob to import tracker torrent data and updating diff --git a/src/common.rs b/src/common.rs index f7bb65d6..fb021138 100644 --- a/src/common.rs +++ b/src/common.rs @@ -52,6 +52,7 @@ pub struct AppData { pub profile_service: Arc, pub ban_service: Arc, pub about_service: Arc, + pub listing_service: Arc, } impl AppData { @@ -90,6 +91,7 @@ impl AppData { profile_service: Arc, ban_service: Arc, about_service: Arc, + listing_service: Arc, ) -> AppData { AppData { cfg, @@ -125,6 +127,7 @@ impl AppData { profile_service, ban_service, about_service, + listing_service, } } } diff --git a/src/web/api/server/v1/contexts/user/handlers.rs b/src/web/api/server/v1/contexts/user/handlers.rs index e22ed325..8712d32c 100644 --- a/src/web/api/server/v1/contexts/user/handlers.rs +++ b/src/web/api/server/v1/contexts/user/handlers.rs @@ -181,3 +181,30 @@ fn api_base_url(host: &str) -> String { // See https://github.com/torrust/torrust-index/issues/131 format!("http://{host}") } + +/// It handles the request to get all the users. +/// +/// It returns: +/// +/// - `200` response with a json containing a list with all the users and their profiles [`Vec`](crate::models::torrent_tag::UserProfile). +/// - Other error status codes if there is a database error. +/// +/// Refer to the [API endpoint documentation](crate::web::api::server::v1::contexts::user) +/// for more information about this endpoint. +/// +/// # Errors +/// +/// It returns an error if: +/// There is a database error +/// There is a problem authorizing the action. +/// The user is not authorized to perform the action +#[allow(clippy::unused_async)] +pub async fn get_all_handler( + State(app_data): State>, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, +) -> Response { + match app_data.listing_service.get_all(maybe_user_id).await { + Ok(users) => Json(crate::web::api::server::v1::responses::OkResponseData { data: users }).into_response(), + Err(error) => error.into_response(), + } +} diff --git a/src/web/api/server/v1/contexts/user/routes.rs b/src/web/api/server/v1/contexts/user/routes.rs index 9daabc18..82e79f51 100644 --- a/src/web/api/server/v1/contexts/user/routes.rs +++ b/src/web/api/server/v1/contexts/user/routes.rs @@ -7,8 +7,8 @@ use axum::routing::{delete, get, post}; use axum::Router; use super::handlers::{ - ban_handler, change_password_handler, email_verification_handler, login_handler, registration_handler, renew_token_handler, - verify_token_handler, + ban_handler, change_password_handler, email_verification_handler, get_all_handler, login_handler, registration_handler, + renew_token_handler, verify_token_handler, }; use crate::common::AppData; @@ -38,3 +38,8 @@ pub fn router(app_data: Arc) -> Router { // code-review: should not this be a POST method? We add the user to the blacklist. We do not delete the user. .route("/ban/:user", delete(ban_handler).with_state(app_data)) } + +/// Routes for the [`user`](crate::web::api::server::v1::contexts::user) API context. +pub fn router_for_multiple_resources(app_data: Arc) -> Router { + Router::new().route("/", get(get_all_handler).with_state(app_data)) +} From 83aaea261893468125f1595f9e31a0eb2def2caf Mon Sep 17 00:00:00 2001 From: Mario Date: Wed, 8 Jan 2025 18:03:48 +0100 Subject: [PATCH 06/14] docs: [#658] fixed docs CI error --- src/web/api/server/v1/contexts/user/handlers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/api/server/v1/contexts/user/handlers.rs b/src/web/api/server/v1/contexts/user/handlers.rs index 8712d32c..b1f4f270 100644 --- a/src/web/api/server/v1/contexts/user/handlers.rs +++ b/src/web/api/server/v1/contexts/user/handlers.rs @@ -186,7 +186,7 @@ fn api_base_url(host: &str) -> String { /// /// It returns: /// -/// - `200` response with a json containing a list with all the users and their profiles [`Vec`](crate::models::torrent_tag::UserProfile). +/// - `200` response with a json containing a list with all the users and their profiles [`Vec`](crate::models::user::UserProfile). /// - Other error status codes if there is a database error. /// /// Refer to the [API endpoint documentation](crate::web::api::server::v1::contexts::user) From 23dfbdd3180120f2476c419faddc116afffc3663 Mon Sep 17 00:00:00 2001 From: Mario Date: Mon, 13 Jan 2025 23:41:03 +0100 Subject: [PATCH 07/14] ci: [#658] FIxed Mysql E2E tests failing --- src/databases/mysql.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 6d529680..05e41485 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -148,7 +148,7 @@ impl Database for Mysql { } async fn get_user_profile_from_username(&self, username: &str) -> Result { - query_as::<_, UserProfile>(r#"SELECT user_id, username, COALESCE(email, "") as email FROM torrust_user_profiles"#) + query_as::<_, UserProfile>(r#"SELECT user_id, username, COALESCE(email, "") as email, email_verified, COALESCE(bio, "") as bio, COALESCE(avatar, "") as avatar FROM torrust_user_profiles WHERE username = ?"#) .bind(username) .fetch_one(&self.pool) .await From 185e17c5aecb6df116b28fd60776e220d511c51f Mon Sep 17 00:00:00 2001 From: Mario Date: Tue, 14 Jan 2025 12:31:22 +0100 Subject: [PATCH 08/14] refactor: [#658] modified naming to make the code more self explanatory --- src/services/user.rs | 8 +++----- src/web/api/server/v1/contexts/user/handlers.rs | 8 ++++---- src/web/api/server/v1/contexts/user/routes.rs | 6 +++--- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/services/user.rs b/src/services/user.rs index 98eaae12..ee35b808 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -338,14 +338,12 @@ impl ListingService { } } - /// Gets all users. + /// Returns a list of all the user profiles. /// /// # Errors /// - /// This function will return a: - /// - /// * There is a database error retrieving the users - pub async fn get_all(&self, maybe_user_id: Option) -> Result, ServiceError> { + /// This function will return a error if there is a database error retrieving the profiles: + pub async fn get_user_profiles(&self, maybe_user_id: Option) -> Result, ServiceError> { self.authorization_service.authorize(ACTION::GetUsers, maybe_user_id).await?; let users = self.user_profile_repository.get_all_user_profiles().await?; diff --git a/src/web/api/server/v1/contexts/user/handlers.rs b/src/web/api/server/v1/contexts/user/handlers.rs index b1f4f270..2692fcf0 100644 --- a/src/web/api/server/v1/contexts/user/handlers.rs +++ b/src/web/api/server/v1/contexts/user/handlers.rs @@ -182,11 +182,11 @@ fn api_base_url(host: &str) -> String { format!("http://{host}") } -/// It handles the request to get all the users. +/// It handles the request to get all the user profiles. /// /// It returns: /// -/// - `200` response with a json containing a list with all the users and their profiles [`Vec`](crate::models::user::UserProfile). +/// - `200` response with a json containing a list with all the user profiles [`Vec`](crate::models::user::UserProfile). /// - Other error status codes if there is a database error. /// /// Refer to the [API endpoint documentation](crate::web::api::server::v1::contexts::user) @@ -199,11 +199,11 @@ fn api_base_url(host: &str) -> String { /// There is a problem authorizing the action. /// The user is not authorized to perform the action #[allow(clippy::unused_async)] -pub async fn get_all_handler( +pub async fn get_user_profiles_handler( State(app_data): State>, ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, ) -> Response { - match app_data.listing_service.get_all(maybe_user_id).await { + match app_data.listing_service.get_user_profiles(maybe_user_id).await { Ok(users) => Json(crate::web::api::server::v1::responses::OkResponseData { data: users }).into_response(), Err(error) => error.into_response(), } diff --git a/src/web/api/server/v1/contexts/user/routes.rs b/src/web/api/server/v1/contexts/user/routes.rs index 82e79f51..138fb60d 100644 --- a/src/web/api/server/v1/contexts/user/routes.rs +++ b/src/web/api/server/v1/contexts/user/routes.rs @@ -7,8 +7,8 @@ use axum::routing::{delete, get, post}; use axum::Router; use super::handlers::{ - ban_handler, change_password_handler, email_verification_handler, get_all_handler, login_handler, registration_handler, - renew_token_handler, verify_token_handler, + ban_handler, change_password_handler, email_verification_handler, get_user_profiles_handler, login_handler, + registration_handler, renew_token_handler, verify_token_handler, }; use crate::common::AppData; @@ -41,5 +41,5 @@ pub fn router(app_data: Arc) -> Router { /// Routes for the [`user`](crate::web::api::server::v1::contexts::user) API context. pub fn router_for_multiple_resources(app_data: Arc) -> Router { - Router::new().route("/", get(get_all_handler).with_state(app_data)) + Router::new().route("/", get(get_user_profiles_handler).with_state(app_data)) } From b4108dc0994893258b7162de528b47d980d6ab28 Mon Sep 17 00:00:00 2001 From: Mario Date: Tue, 14 Jan 2025 12:32:13 +0100 Subject: [PATCH 09/14] feat: [#658] new API endpoint for listing user profiles --- src/web/api/server/v1/routes.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/web/api/server/v1/routes.rs b/src/web/api/server/v1/routes.rs index 1826427d..5b7d0f1f 100644 --- a/src/web/api/server/v1/routes.rs +++ b/src/web/api/server/v1/routes.rs @@ -33,6 +33,7 @@ pub fn router(app_data: Arc) -> Router { let v1_api_routes = Router::new() .route("/", get(redirect_to_about)) .nest("/user", user::routes::router(app_data.clone())) + .nest("/users", user::routes::router_for_multiple_resources(app_data.clone())) .nest("/about", about::routes::router(app_data.clone())) .nest("/category", category::routes::router(app_data.clone())) .nest("/tag", tag::routes::router_for_single_resources(app_data.clone())) From ef0c1e7570bbdfd3f6f7cc6271ff57e38bcfd695 Mon Sep 17 00:00:00 2001 From: Mario Date: Tue, 14 Jan 2025 13:03:17 +0100 Subject: [PATCH 10/14] refactor: [#658] More renaming --- src/services/authorization.rs | 4 ++-- src/services/user.rs | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/services/authorization.rs b/src/services/authorization.rs index 01dfebfd..19e4a6b5 100644 --- a/src/services/authorization.rs +++ b/src/services/authorization.rs @@ -52,7 +52,7 @@ pub enum ACTION { GetCanonicalInfoHash, ChangePassword, BanUser, - GetUsers, + GetUserProfiles, } pub struct Service { @@ -249,7 +249,7 @@ impl Default for CasbinConfiguration { admin, GetCanonicalInfoHash admin, ChangePassword admin, BanUser - admin, GetUsers + admin, GetUserProfiles registered, GetAboutPage registered, GetLicensePage registered, GetCategories diff --git a/src/services/user.rs b/src/services/user.rs index ee35b808..352c6030 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -344,7 +344,9 @@ impl ListingService { /// /// This function will return a error if there is a database error retrieving the profiles: pub async fn get_user_profiles(&self, maybe_user_id: Option) -> Result, ServiceError> { - self.authorization_service.authorize(ACTION::GetUsers, maybe_user_id).await?; + self.authorization_service + .authorize(ACTION::GetUserProfiles, maybe_user_id) + .await?; let users = self.user_profile_repository.get_all_user_profiles().await?; From ae1e6a9ffd0706962eaf22384020eab84c49fc9b Mon Sep 17 00:00:00 2001 From: Mario Date: Thu, 16 Jan 2025 21:04:16 +0100 Subject: [PATCH 11/14] refactor: [#796] minor variable renaming --- src/services/user.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/user.rs b/src/services/user.rs index 352c6030..05a4bd0b 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -348,9 +348,9 @@ impl ListingService { .authorize(ACTION::GetUserProfiles, maybe_user_id) .await?; - let users = self.user_profile_repository.get_all_user_profiles().await?; + let user_profiles = self.user_profile_repository.get_all_user_profiles().await?; - Ok(users) + Ok(user_profiles) } } From f1259cc9e57e0f776e8b855c633654a9a2a7147a Mon Sep 17 00:00:00 2001 From: Mario Date: Tue, 21 Jan 2025 23:16:46 +0100 Subject: [PATCH 12/14] feat: [#796] added pagination --- src/app.rs | 1 + src/config/v2/api.rs | 18 +++++ src/databases/database.rs | 6 +- src/databases/mysql.rs | 29 ++++++-- src/databases/sqlite.rs | 29 ++++++-- src/models/response.rs | 8 +++ src/services/authorization.rs | 4 +- src/services/user.rs | 69 ++++++++++++++++--- .../api/server/v1/contexts/user/handlers.rs | 10 ++- 9 files changed, 151 insertions(+), 23 deletions(-) diff --git a/src/app.rs b/src/app.rs index 2448ffd6..64e58dfc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -161,6 +161,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running let about_service = Arc::new(about::Service::new(authorization_service.clone())); let listing_service = Arc::new(user::ListingService::new( + configuration.clone(), user_profile_repository.clone(), authorization_service.clone(), )) diff --git a/src/config/v2/api.rs b/src/config/v2/api.rs index 678c52d7..10347869 100644 --- a/src/config/v2/api.rs +++ b/src/config/v2/api.rs @@ -10,6 +10,14 @@ pub struct Api { /// The maximum page size for torrent lists. #[serde(default = "Api::default_max_torrent_page_size")] pub max_torrent_page_size: u8, + + /// The default page size for user profile lists. + #[serde(default = "Api::default_user_profile_page_size")] + pub default_user_profile_page_size: u8, + + /// The maximum page size for user profile lists. + #[serde(default = "Api::default_max_user_profile_page_size")] + pub max_user_profile_page_size: u8, } impl Default for Api { @@ -17,6 +25,8 @@ impl Default for Api { Self { default_torrent_page_size: Api::default_default_torrent_page_size(), max_torrent_page_size: Api::default_max_torrent_page_size(), + default_user_profile_page_size: Api::default_user_profile_page_size(), + max_user_profile_page_size: Api::default_max_user_profile_page_size(), } } } @@ -29,4 +39,12 @@ impl Api { fn default_max_torrent_page_size() -> u8 { 30 } + + fn default_user_profile_page_size() -> u8 { + 10 + } + + fn default_max_user_profile_page_size() -> u8 { + 100 + } } diff --git a/src/databases/database.rs b/src/databases/database.rs index 40487b61..7d41758d 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -7,7 +7,7 @@ use url::Url; use crate::databases::mysql::Mysql; use crate::databases::sqlite::Sqlite; use crate::models::category::CategoryId; -use crate::models::response::TorrentsResponse; +use crate::models::response::{TorrentsResponse, UserProfilesResponse}; use crate::models::torrent::{Metadata, TorrentListing}; use crate::models::torrent_file::{DbTorrent, Torrent, TorrentFile}; use crate::models::torrent_tag::{TagId, TorrentTag}; @@ -143,8 +143,8 @@ pub trait Database: Sync + Send { /// Get `UserProfile` from `username`. async fn get_user_profile_from_username(&self, username: &str) -> Result; - /// Get all user profiles as `Vec`. - async fn get_user_profiles(&self) -> Result, Error>; + /// Get all user profiles in a paginated form as `UserProfilesResponse`. + async fn get_user_profiles_paginated(&self, offset: u64, page_size: u8) -> Result; /// Get `UserCompact` from `user_id`. async fn get_user_compact_from_id(&self, user_id: i64) -> Result; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 05e41485..c221f4b0 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -12,7 +12,7 @@ use super::database::TABLES_TO_TRUNCATE; use crate::databases::database; use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact}; use crate::models::category::CategoryId; -use crate::models::response::TorrentsResponse; +use crate::models::response::{TorrentsResponse, UserProfilesResponse}; use crate::models::torrent::{Metadata, TorrentListing}; use crate::models::torrent_file::{ DbTorrent, DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentHttpSeedUrl, DbTorrentNode, Torrent, TorrentFile, @@ -155,11 +155,32 @@ impl Database for Mysql { .map_err(|_| database::Error::UserNotFound) } - async fn get_user_profiles(&self) -> Result, database::Error> { - query_as::<_, UserProfile>(r"SELECT * FROM torrust_user_profiles") + async fn get_user_profiles_paginated(&self, offset: u64, limit: u8) -> Result { + let mut query_string = format!("SELECT * FROM torrust_user_profiles"); + + let count_query = format!("SELECT COUNT(*) as count FROM ({query_string}) AS count_table"); + + let count_result: Result = query_as(&count_query) + .fetch_one(&self.pool) + .await + .map(|(v,)| v) + .map_err(|_| database::Error::Error); + + let count = count_result?; + + query_string = format!("{query_string} LIMIT ?, ?"); + + let res: Vec = sqlx::query_as::<_, UserProfile>(&query_string) + .bind(i64::saturating_add_unsigned(0, offset)) + .bind(limit) .fetch_all(&self.pool) .await - .map_err(|_| database::Error::Error) + .map_err(|_| database::Error::Error)?; + + Ok(UserProfilesResponse { + total: u32::try_from(count).expect("variable `count` is larger than u32"), + results: res, + }) } async fn get_user_compact_from_id(&self, user_id: i64) -> Result { diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 8b91c155..23d8a6eb 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -12,7 +12,7 @@ use super::database::TABLES_TO_TRUNCATE; use crate::databases::database; use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact}; use crate::models::category::CategoryId; -use crate::models::response::TorrentsResponse; +use crate::models::response::{TorrentsResponse, UserProfilesResponse}; use crate::models::torrent::{Metadata, TorrentListing}; use crate::models::torrent_file::{ DbTorrent, DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentHttpSeedUrl, DbTorrentNode, Torrent, TorrentFile, @@ -156,11 +156,32 @@ impl Database for Sqlite { .map_err(|_| database::Error::UserNotFound) } - async fn get_user_profiles(&self) -> Result, database::Error> { - query_as::<_, UserProfile>("SELECT * FROM torrust_user_profiles") + async fn get_user_profiles_paginated(&self, offset: u64, limit: u8) -> Result { + let mut query_string = format!("SELECT * FROM torrust_user_profiles"); + + let count_query = format!("SELECT COUNT(*) as count FROM ({query_string}) AS count_table"); + + let count_result: Result = query_as(&count_query) + .fetch_one(&self.pool) + .await + .map(|(v,)| v) + .map_err(|_| database::Error::Error); + + let count = count_result?; + + query_string = format!("{query_string} LIMIT ?, ?"); + + let res: Vec = sqlx::query_as::<_, UserProfile>(&query_string) + .bind(i64::saturating_add_unsigned(0, offset)) + .bind(limit) .fetch_all(&self.pool) .await - .map_err(|_| database::Error::Error) + .map_err(|_| database::Error::Error)?; + + Ok(UserProfilesResponse { + total: u32::try_from(count).expect("variable `count` is larger than u32"), + results: res, + }) } async fn get_user_compact_from_id(&self, user_id: i64) -> Result { diff --git a/src/models/response.rs b/src/models/response.rs index e173822b..8f2806b1 100644 --- a/src/models/response.rs +++ b/src/models/response.rs @@ -3,6 +3,7 @@ use url::Url; use super::category::Category; use super::torrent::TorrentId; +use super::user::UserProfile; use crate::databases::database::Category as DatabaseCategory; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::TorrentFile; @@ -123,3 +124,10 @@ pub struct TorrentsResponse { pub total: u32, pub results: Vec, } + +#[allow(clippy::module_name_repetitions)] +#[derive(Serialize, Deserialize, Debug, sqlx::FromRow)] +pub struct UserProfilesResponse { + pub total: u32, + pub results: Vec, +} diff --git a/src/services/authorization.rs b/src/services/authorization.rs index 19e4a6b5..df3d30c8 100644 --- a/src/services/authorization.rs +++ b/src/services/authorization.rs @@ -52,7 +52,7 @@ pub enum ACTION { GetCanonicalInfoHash, ChangePassword, BanUser, - GetUserProfiles, + GenerateUserProfilesListing, } pub struct Service { @@ -249,7 +249,7 @@ impl Default for CasbinConfiguration { admin, GetCanonicalInfoHash admin, ChangePassword admin, BanUser - admin, GetUserProfiles + admin, GenerateUserProfilesListing registered, GetAboutPage registered, GetLicensePage registered, GetCategories diff --git a/src/services/user.rs b/src/services/user.rs index 05a4bd0b..083a0e2c 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -8,6 +8,7 @@ use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; #[cfg(test)] use mockall::automock; use pbkdf2::password_hash::rand_core::OsRng; +use serde_derive::Deserialize; use tracing::{debug, info}; use super::authentication::DbUserAuthenticationRepository; @@ -17,6 +18,7 @@ use crate::databases::database::{Database, Error}; use crate::errors::ServiceError; use crate::mailer; use crate::mailer::VerifyClaims; +use crate::models::response::UserProfilesResponse; use crate::models::user::{UserCompact, UserId, UserProfile, Username}; use crate::services::authentication::verify_password; use crate::utils::validation::validate_email_address; @@ -29,6 +31,20 @@ fn no_email() -> String { String::new() } +/// User request to generate a user profile listing. +#[derive(Debug, Deserialize)] +pub struct ListingRequest { + pub page_size: Option, + pub page: Option, +} + +/// Internal specification for user profiles listings. +#[derive(Debug, Deserialize)] +pub struct ListingSpecification { + pub offset: u64, + pub page_size: u8, +} + pub struct RegistrationService { configuration: Arc, mailer: Arc, @@ -322,6 +338,7 @@ impl BanService { } pub struct ListingService { + configuration: Arc, user_profile_repository: Arc, authorization_service: Arc, } @@ -329,28 +346,62 @@ pub struct ListingService { impl ListingService { #[must_use] pub fn new( + configuration: Arc, user_profile_repository: Arc, authorization_service: Arc, ) -> Self { Self { + configuration, user_profile_repository, authorization_service, } } - /// Returns a list of all the user profiles. + /// Returns a list of all the user profiles matching the search criteria. /// /// # Errors /// - /// This function will return a error if there is a database error retrieving the profiles: - pub async fn get_user_profiles(&self, maybe_user_id: Option) -> Result, ServiceError> { + /// Returns a `ServiceError::DatabaseError` if the database query fails. + pub async fn generate_user_profile_listing( + &self, + request: &ListingRequest, + maybe_user_id: Option, + ) -> Result { self.authorization_service - .authorize(ACTION::GetUserProfiles, maybe_user_id) + .authorize(ACTION::GenerateUserProfilesListing, maybe_user_id) .await?; - let user_profiles = self.user_profile_repository.get_all_user_profiles().await?; + let user_profile_listing_specification = self.listing_specification_from_user_request(request).await; + + let user_profiles_response = self + .user_profile_repository + .generate_listing(&user_profile_listing_specification) + .await?; - Ok(user_profiles) + Ok(user_profiles_response) + } + + /// It converts the user listing request into an internal listing + /// specification. + async fn listing_specification_from_user_request(&self, request: &ListingRequest) -> ListingSpecification { + let settings = self.configuration.settings.read().await; + let default_user_profile_page_size = settings.api.default_user_profile_page_size; + let max_user_profile_page_size = settings.api.max_user_profile_page_size; + drop(settings); + + let page = request.page.unwrap_or(0); + let page_size = request.page_size.unwrap_or(default_user_profile_page_size); + + // Guard that page size does not exceed the maximum + let page_size = if page_size > max_user_profile_page_size { + max_user_profile_page_size + } else { + page_size + }; + + let offset = u64::from(page * u32::from(page_size)); + + ListingSpecification { offset, page_size } } } @@ -451,8 +502,10 @@ impl DbUserProfileRepository { /// # Errors /// /// It returns an error if there is a database error. - pub async fn get_all_user_profiles(&self) -> Result, Error> { - self.database.get_user_profiles().await + pub async fn generate_listing(&self, specification: &ListingSpecification) -> Result { + self.database + .get_user_profiles_paginated(specification.offset, specification.page_size) + .await } } diff --git a/src/web/api/server/v1/contexts/user/handlers.rs b/src/web/api/server/v1/contexts/user/handlers.rs index 2692fcf0..d49163c7 100644 --- a/src/web/api/server/v1/contexts/user/handlers.rs +++ b/src/web/api/server/v1/contexts/user/handlers.rs @@ -2,7 +2,7 @@ //! context. use std::sync::Arc; -use axum::extract::{self, Host, Path, State}; +use axum::extract::{self, Host, Path, Query, State}; use axum::response::{IntoResponse, Response}; use axum::Json; use serde::Deserialize; @@ -10,6 +10,7 @@ use serde::Deserialize; use super::forms::{ChangePasswordForm, JsonWebToken, LoginForm, RegistrationForm}; use super::responses::{self}; use crate::common::AppData; +use crate::services::user::ListingRequest; use crate::web::api::server::v1::extractors::optional_user_id::ExtractOptionalLoggedInUser; use crate::web::api::server::v1::responses::OkResponseData; @@ -201,9 +202,14 @@ fn api_base_url(host: &str) -> String { #[allow(clippy::unused_async)] pub async fn get_user_profiles_handler( State(app_data): State>, + Query(criteria): Query, ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, ) -> Response { - match app_data.listing_service.get_user_profiles(maybe_user_id).await { + match app_data + .listing_service + .generate_user_profile_listing(&criteria, maybe_user_id) + .await + { Ok(users) => Json(crate::web::api::server::v1::responses::OkResponseData { data: users }).into_response(), Err(error) => error.into_response(), } From 3efaf48ebead8fcd3136db293492ccfd26bc83ad Mon Sep 17 00:00:00 2001 From: Mario Date: Wed, 22 Jan 2025 11:23:27 +0100 Subject: [PATCH 13/14] refactor: [#796] fix minor clippy error --- src/databases/mysql.rs | 2 +- src/databases/sqlite.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index c221f4b0..99eaacbe 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -156,7 +156,7 @@ impl Database for Mysql { } async fn get_user_profiles_paginated(&self, offset: u64, limit: u8) -> Result { - let mut query_string = format!("SELECT * FROM torrust_user_profiles"); + let mut query_string = "SELECT * FROM torrust_user_profiles".to_string(); let count_query = format!("SELECT COUNT(*) as count FROM ({query_string}) AS count_table"); diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 23d8a6eb..337a5858 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -157,7 +157,7 @@ impl Database for Sqlite { } async fn get_user_profiles_paginated(&self, offset: u64, limit: u8) -> Result { - let mut query_string = format!("SELECT * FROM torrust_user_profiles"); + let mut query_string = "SELECT * FROM torrust_user_profiles".to_string(); let count_query = format!("SELECT COUNT(*) as count FROM ({query_string}) AS count_table"); From 1c62a9e4a26248e518b6b624e1700f49c4e7035f Mon Sep 17 00:00:00 2001 From: Mario Date: Wed, 22 Jan 2025 11:54:53 +0100 Subject: [PATCH 14/14] refactor: [#796] minor correction in handler comment --- src/web/api/server/v1/contexts/user/handlers.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/web/api/server/v1/contexts/user/handlers.rs b/src/web/api/server/v1/contexts/user/handlers.rs index d49163c7..3ab53dff 100644 --- a/src/web/api/server/v1/contexts/user/handlers.rs +++ b/src/web/api/server/v1/contexts/user/handlers.rs @@ -185,13 +185,7 @@ fn api_base_url(host: &str) -> String { /// It handles the request to get all the user profiles. /// -/// It returns: -/// -/// - `200` response with a json containing a list with all the user profiles [`Vec`](crate::models::user::UserProfile). -/// - Other error status codes if there is a database error. -/// -/// Refer to the [API endpoint documentation](crate::web::api::server::v1::contexts::user) -/// for more information about this endpoint. +///It returns a list of user profiles matching the search criteria. /// /// # Errors ///