Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ 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(),
))
.clone();

// Build app container

let app_data = Arc::new(AppData::new(
Expand Down Expand Up @@ -194,6 +201,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
Expand Down
3 changes: 3 additions & 0 deletions src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ pub struct AppData {
pub profile_service: Arc<user::ProfileService>,
pub ban_service: Arc<user::BanService>,
pub about_service: Arc<about::Service>,
pub listing_service: Arc<user::ListingService>,
}

impl AppData {
Expand Down Expand Up @@ -90,6 +91,7 @@ impl AppData {
profile_service: Arc<user::ProfileService>,
ban_service: Arc<user::BanService>,
about_service: Arc<about::Service>,
listing_service: Arc<user::ListingService>,
) -> AppData {
AppData {
cfg,
Expand Down Expand Up @@ -125,6 +127,7 @@ impl AppData {
profile_service,
ban_service,
about_service,
listing_service,
}
}
}
18 changes: 18 additions & 0 deletions src/config/v2/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,23 @@ 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 {
fn default() -> Self {
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(),
}
}
}
Expand All @@ -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
}
}
5 changes: 4 additions & 1 deletion src/databases/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -143,6 +143,9 @@ pub trait Database: Sync + Send {
/// Get `UserProfile` from `username`.
async fn get_user_profile_from_username(&self, username: &str) -> Result<UserProfile, Error>;

/// Get all user profiles in a paginated form as `UserProfilesResponse`.
async fn get_user_profiles_paginated(&self, offset: u64, page_size: u8) -> Result<UserProfilesResponse, Error>;

/// Get `UserCompact` from `user_id`.
async fn get_user_compact_from_id(&self, user_id: i64) -> Result<UserCompact, Error>;

Expand Down
30 changes: 29 additions & 1 deletion src/databases/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -155,6 +155,34 @@ impl Database for Mysql {
.map_err(|_| database::Error::UserNotFound)
}

async fn get_user_profiles_paginated(&self, offset: u64, limit: u8) -> Result<UserProfilesResponse, database::Error> {
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");

let count_result: Result<i64, database::Error> = 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<UserProfile> = 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)?;

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<UserCompact, database::Error> {
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)
Expand Down
30 changes: 29 additions & 1 deletion src/databases/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -156,6 +156,34 @@ impl Database for Sqlite {
.map_err(|_| database::Error::UserNotFound)
}

async fn get_user_profiles_paginated(&self, offset: u64, limit: u8) -> Result<UserProfilesResponse, database::Error> {
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");

let count_result: Result<i64, database::Error> = 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<UserProfile> = 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)?;

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<UserCompact, database::Error> {
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)
Expand Down
8 changes: 8 additions & 0 deletions src/models/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -123,3 +124,10 @@ pub struct TorrentsResponse {
pub total: u32,
pub results: Vec<TorrentListing>,
}

#[allow(clippy::module_name_repetitions)]
#[derive(Serialize, Deserialize, Debug, sqlx::FromRow)]
pub struct UserProfilesResponse {
pub total: u32,
pub results: Vec<UserProfile>,
}
2 changes: 2 additions & 0 deletions src/services/authorization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ pub enum ACTION {
GetCanonicalInfoHash,
ChangePassword,
BanUser,
GenerateUserProfilesListing,
}

pub struct Service {
Expand Down Expand Up @@ -248,6 +249,7 @@ impl Default for CasbinConfiguration {
admin, GetCanonicalInfoHash
admin, ChangePassword
admin, BanUser
admin, GenerateUserProfilesListing
registered, GetAboutPage
registered, GetLicensePage
registered, GetCategories
Expand Down
95 changes: 95 additions & 0 deletions src/services/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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<u8>,
pub page: Option<u32>,
}

/// Internal specification for user profiles listings.
#[derive(Debug, Deserialize)]
pub struct ListingSpecification {
pub offset: u64,
pub page_size: u8,
}

pub struct RegistrationService {
configuration: Arc<Configuration>,
mailer: Arc<mailer::Service>,
Expand Down Expand Up @@ -321,6 +337,74 @@ impl BanService {
}
}

pub struct ListingService {
configuration: Arc<Configuration>,
user_profile_repository: Arc<DbUserProfileRepository>,
authorization_service: Arc<authorization::Service>,
}

impl ListingService {
#[must_use]
pub fn new(
configuration: Arc<Configuration>,
user_profile_repository: Arc<DbUserProfileRepository>,
authorization_service: Arc<authorization::Service>,
) -> Self {
Self {
configuration,
user_profile_repository,
authorization_service,
}
}

/// Returns a list of all the user profiles matching the search criteria.
///
/// # Errors
///
/// Returns a `ServiceError::DatabaseError` if the database query fails.
pub async fn generate_user_profile_listing(
&self,
request: &ListingRequest,
maybe_user_id: Option<UserId>,
) -> Result<UserProfilesResponse, ServiceError> {
self.authorization_service
.authorize(ACTION::GenerateUserProfilesListing, maybe_user_id)
.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_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 }
}
}

#[cfg_attr(test, automock)]
#[async_trait]
pub trait Repository: Sync + Send {
Expand Down Expand Up @@ -412,6 +496,17 @@ impl DbUserProfileRepository {
pub async fn get_user_profile_from_username(&self, username: &str) -> Result<UserProfile, Error> {
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 generate_listing(&self, specification: &ListingSpecification) -> Result<UserProfilesResponse, Error> {
self.database
.get_user_profiles_paginated(specification.offset, specification.page_size)
.await
}
}

pub struct DbBannedUserList {
Expand Down
Loading
Loading