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
75 changes: 74 additions & 1 deletion src/databases/database.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
use std::fmt;
use std::str::FromStr;

use async_trait::async_trait;
use bittorrent_primitives::info_hash::InfoHash;
use chrono::{DateTime, NaiveDateTime, Utc};
Expand Down Expand Up @@ -73,6 +76,74 @@ pub enum Sorting {
SizeDesc,
}

/// Sorting options for users.
#[derive(Clone, Copy, Debug, Deserialize)]
pub enum UsersSorting {
DateRegisteredNewest,
DateRegisteredOldest,
UsernameAZ,
UsernameZA,
}

impl FromStr for UsersSorting {
type Err = UsersSortingParseError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"DateRegisteredNewest" => Ok(UsersSorting::DateRegisteredNewest),
"DateRegisteredOldest" => Ok(UsersSorting::DateRegisteredOldest),
"UsernameAZ" => Ok(UsersSorting::UsernameAZ),
"UsernameZA" => Ok(UsersSorting::UsernameZA),
_ => Err(UsersSortingParseError),
}
}
}

// Custom error type for parsing failures
#[derive(Debug)]
pub struct UsersSortingParseError;

impl fmt::Display for UsersSortingParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Invalid sorting option")
}
}

impl std::error::Error for UsersSortingParseError {}

/// Sorting options for users.
#[derive(Clone, Copy, Debug, Deserialize)]
pub enum UsersFilters {
EmailVerified,
EmailNotVerified,
TorrentUploader,
}

impl FromStr for UsersFilters {
type Err = UsersFiltersParseError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"EmailVerified" => Ok(UsersFilters::EmailVerified),
"EmailNotVerified" => Ok(UsersFilters::EmailNotVerified),
"TorrentUploader" => Ok(UsersFilters::TorrentUploader),
_ => Err(UsersFiltersParseError),
}
}
}

// Custom error type for parsing failures
#[derive(Debug)]
pub struct UsersFiltersParseError;

impl fmt::Display for UsersFiltersParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Invalid filter option")
}
}

impl std::error::Error for UsersFiltersParseError {}

/// Database errors.
#[derive(Debug)]
pub enum Error {
Expand Down Expand Up @@ -143,10 +214,12 @@ 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 and sorted form as `UserProfilesResponse` from `search`,`offset` and `page_size`.
/// Get all user profiles in a paginated and sorted form as `UserProfilesResponse` from `search`, `filters`, `sort`, `offset` and `page_size`.
async fn get_user_profiles_search_paginated(
&self,
search: &Option<String>,
filters: &Option<Vec<UsersFilters>>,
sort: Option<UsersSorting>,
offset: u64,
page_size: u8,
) -> Result<UserProfilesResponse, Error>;
Expand Down
51 changes: 46 additions & 5 deletions src/databases/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions};
use sqlx::{query, query_as, Acquire, ConnectOptions, MySqlPool};
use url::Url;

use super::database::TABLES_TO_TRUNCATE;
use super::database::{UsersFilters, UsersSorting, TABLES_TO_TRUNCATE};
use crate::databases::database;
use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact};
use crate::models::category::CategoryId;
Expand All @@ -19,7 +19,7 @@ use crate::models::torrent_file::{
};
use crate::models::torrent_tag::{TagId, TorrentTag};
use crate::models::tracker_key::TrackerKey;
use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile};
use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserListing, UserProfile};
use crate::services::torrent::{CanonicalInfoHashGroup, DbTorrentInfoHash};
use crate::utils::clock::{self, datetime_now, DATETIME_FORMAT};
use crate::utils::hex::from_bytes;
Expand Down Expand Up @@ -158,6 +158,8 @@ impl Database for Mysql {
async fn get_user_profiles_search_paginated(
&self,
search: &Option<String>,
filters: &Option<Vec<UsersFilters>>,
sort: Option<UsersSorting>,
offset: u64,
limit: u8,
) -> Result<UserProfilesResponse, database::Error> {
Expand All @@ -166,7 +168,46 @@ impl Database for Mysql {
Some(v) => format!("%{v}%"),
};

let mut query_string = "SELECT * FROM torrust_user_profiles WHERE username LIKE ?".to_string();
let sort_query: String = match sort {
Some(UsersSorting::DateRegisteredNewest) => "date_registered ASC".to_string(),
Some(UsersSorting::DateRegisteredOldest) => "date_registered DESC".to_string(),
Some(UsersSorting::UsernameAZ) | None => "username ASC".to_string(),
Some(UsersSorting::UsernameZA) => "username DESC".to_string(),
};

let (join_filters, where_filters) = if let Some(filters) = filters {
let (mut join_filters_query, mut where_filters_query) = (String::new(), String::new());
for filter in filters {
match filter {
UsersFilters::TorrentUploader => join_filters_query.push_str(
"INNER JOIN torrust_torrents tt
ON tu.user_id = tt.uploader_id ",
),
UsersFilters::EmailNotVerified => where_filters_query.push_str(" AND email_verified = false"),
UsersFilters::EmailVerified => where_filters_query.push_str(" AND email_verified = true"),
}
}
(join_filters_query, where_filters_query)
} else {
(String::new(), String::new())
};

let mut query_string = format!(
"SELECT
tp.user_id,
tp.username,
tp.email,
tp.email_verified,
tu.date_registered,
tu.administrator
FROM torrust_user_profiles tp
INNER JOIN torrust_users tu
ON tp.user_id = tu.user_id
{join_filters}
WHERE username LIKE ?
{where_filters}
"
);

let count_query = format!("SELECT COUNT(*) as count FROM ({query_string}) AS count_table");

Expand All @@ -179,9 +220,9 @@ impl Database for Mysql {

let count = count_result?;

query_string = format!("{query_string} LIMIT ?, ?");
query_string = format!("{query_string} ORDER BY {sort_query} LIMIT ?, ?");

let res: Vec<UserProfile> = sqlx::query_as::<_, UserProfile>(&query_string)
let res: Vec<UserListing> = sqlx::query_as::<_, UserListing>(&query_string)
.bind(user_name.clone())
.bind(i64::saturating_add_unsigned(0, offset))
.bind(limit)
Expand Down
51 changes: 46 additions & 5 deletions src/databases/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{query, query_as, Acquire, ConnectOptions, SqlitePool};
use url::Url;

use super::database::TABLES_TO_TRUNCATE;
use super::database::{UsersFilters, UsersSorting, TABLES_TO_TRUNCATE};
use crate::databases::database;
use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact};
use crate::models::category::CategoryId;
Expand All @@ -19,7 +19,7 @@ use crate::models::torrent_file::{
};
use crate::models::torrent_tag::{TagId, TorrentTag};
use crate::models::tracker_key::TrackerKey;
use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile};
use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserListing, UserProfile};
use crate::services::torrent::{CanonicalInfoHashGroup, DbTorrentInfoHash};
use crate::utils::clock::{self, datetime_now, DATETIME_FORMAT};
use crate::utils::hex::from_bytes;
Expand Down Expand Up @@ -159,6 +159,8 @@ impl Database for Sqlite {
async fn get_user_profiles_search_paginated(
&self,
search: &Option<String>,
filters: &Option<Vec<UsersFilters>>,
sort: Option<UsersSorting>,
offset: u64,
limit: u8,
) -> Result<UserProfilesResponse, database::Error> {
Expand All @@ -167,7 +169,46 @@ impl Database for Sqlite {
Some(v) => format!("%{v}%"),
};

let mut query_string = "SELECT * FROM torrust_user_profiles WHERE username LIKE ?".to_string();
let sort_query: String = match sort {
Some(UsersSorting::DateRegisteredNewest) => "date_registered ASC".to_string(),
Some(UsersSorting::DateRegisteredOldest) => "date_registered DESC".to_string(),
Some(UsersSorting::UsernameAZ) | None => "username ASC".to_string(),
Some(UsersSorting::UsernameZA) => "username DESC".to_string(),
};

let (join_filters, where_filters) = if let Some(filters) = filters {
let (mut join_filters_query, mut where_filters_query) = (String::new(), String::new());
for filter in filters {
match filter {
UsersFilters::TorrentUploader => join_filters_query.push_str(
"INNER JOIN torrust_torrents tt
ON tu.user_id = tt.uploader_id ",
),
UsersFilters::EmailNotVerified => where_filters_query.push_str(" AND email_verified = false"),
UsersFilters::EmailVerified => where_filters_query.push_str(" AND email_verified = true"),
}
}
(join_filters_query, where_filters_query)
} else {
(String::new(), String::new())
};

let mut query_string = format!(
"SELECT
tp.user_id,
tp.username,
tp.email,
tp.email_verified,
tu.date_registered,
tu.administrator
FROM torrust_user_profiles tp
INNER JOIN torrust_users tu
ON tp.user_id = tu.user_id
{join_filters}
WHERE username LIKE ?
{where_filters}
"
);

let count_query = format!("SELECT COUNT(*) as count FROM ({query_string}) AS count_table");

Expand All @@ -180,9 +221,9 @@ impl Database for Sqlite {

let count = count_result?;

query_string = format!("{query_string} LIMIT ?, ?");
query_string = format!("{query_string} ORDER BY {sort_query} LIMIT ?, ?");

let res: Vec<UserProfile> = sqlx::query_as::<_, UserProfile>(&query_string)
let res: Vec<UserListing> = sqlx::query_as::<_, UserListing>(&query_string)
.bind(user_name.clone())
.bind(i64::saturating_add_unsigned(0, offset))
.bind(limit)
Expand Down
3 changes: 3 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ pub enum ServiceError {
#[display("Invalid tracker API token.")]
InvalidTrackerToken,
// End tracker errors
#[display("Invalid user listing fields in the URL params.")]
InvalidUserListing,
}

impl From<sqlx::Error> for ServiceError {
Expand Down Expand Up @@ -326,6 +328,7 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode {
ServiceError::TorrentNotFoundInTracker => StatusCode::NOT_FOUND,
ServiceError::InvalidTrackerToken => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::LoggedInUserNotFound => StatusCode::UNAUTHORIZED,
ServiceError::InvalidUserListing => StatusCode::BAD_REQUEST,
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/models/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use url::Url;

use super::category::Category;
use super::torrent::TorrentId;
use super::user::UserProfile;
use super::user::UserListing;
use crate::databases::database::Category as DatabaseCategory;
use crate::models::torrent::TorrentListing;
use crate::models::torrent_file::TorrentFile;
Expand Down Expand Up @@ -129,5 +129,5 @@ pub struct TorrentsResponse {
#[derive(Serialize, Deserialize, Debug, sqlx::FromRow)]
pub struct UserProfilesResponse {
pub total: u32,
pub results: Vec<UserProfile>,
pub results: Vec<UserListing>,
}
11 changes: 11 additions & 0 deletions src/models/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ pub struct UserFull {
pub avatar: String,
}

#[allow(clippy::module_name_repetitions)]
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct UserListing {
pub user_id: UserId,
pub username: String,
pub email: String,
pub email_verified: bool,
pub date_registered: String,
pub administrator: bool,
}

#[allow(clippy::module_name_repetitions)]
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserClaims {
Expand Down
4 changes: 2 additions & 2 deletions src/services/authorization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ pub enum ACTION {
GetCanonicalInfoHash,
ChangePassword,
BanUser,
GenerateUserProfilesListing,
GenerateUserProfileSpecification,
}

pub struct Service {
Expand Down Expand Up @@ -249,7 +249,7 @@ impl Default for CasbinConfiguration {
admin, GetCanonicalInfoHash
admin, ChangePassword
admin, BanUser
admin, GenerateUserProfilesListing
admin, GenerateUserProfileSpecification
registered, GetAboutPage
registered, GetLicensePage
registered, GetCategories
Expand Down
Loading
Loading